Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
15 commits
Select commit Hold shift + click to select a range
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 1 addition & 0 deletions app/build.gradle.kts
Original file line number Diff line number Diff line change
Expand Up @@ -57,6 +57,7 @@ dependencies {
implementation(projects.feature.calendar)
implementation(projects.feature.emotion)
implementation(projects.feature.login)
implementation(projects.feature.setting)

implementation(libs.compose.material.icons.core)
implementation(libs.androidx.activity.compose)
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -21,6 +21,8 @@ import com.gamss.android.feature.emotion.EmotionScreen
import com.gamss.android.feature.emotion.navigation.EmotionKey
import com.gamss.android.feature.home.HomeScreen
import com.gamss.android.feature.home.navigation.HomeKey
import com.gamss.android.feature.setting.SettingScreen
import com.gamss.android.feature.setting.navigation.SettingKey

@Composable
fun MainScreen() {
Expand All @@ -45,10 +47,13 @@ fun MainScreen() {
modifier = Modifier.padding(innerPadding),
entries = navigationState.toEntries(
entryProvider = entryProvider {
entry<HomeKey> { HomeScreen() }
entry<HomeKey> {
HomeScreen(onNavigateToSetting = { navigator.navigate(SettingKey) })
}
entry<ChatKey> { ChattingListScreen() }
entry<CalendarKey> { CalendarScreen() }
entry<EmotionKey> { EmotionScreen() }
entry<SettingKey> { SettingScreen() }
},
),
onBack = {
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -21,4 +21,11 @@ inline fun <T, R> AppResult<T>.map(transform: (T) -> R): AppResult<R> =
is AppResult.Failure -> this
}

inline fun <T> AppResult<T>.mapFailure(
transform: (Throwable) -> Throwable,
): AppResult<T> = when (this) {
is AppResult.Success -> this
is AppResult.Failure -> AppResult.Failure(transform(throwable))
}

fun <T> AppResult<T>.getOrNull(): T? = (this as? AppResult.Success)?.data
Original file line number Diff line number Diff line change
Expand Up @@ -9,7 +9,7 @@ import com.gamss.android.data.local.auth.TokenProviderImpl
import com.gamss.android.data.repository.AuthRepositoryImpl
import com.gamss.android.data.repository.UserRepositoryImpl
import com.gamss.android.domain.repository.AuthRepository
import com.gamss.android.domain.repository.UserRepository
import com.gamss.android.domain.user.UserRepository
Comment thread
seunghee17 marked this conversation as resolved.
import dagger.Binds
import dagger.Module
import dagger.Provides
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -2,15 +2,20 @@ package com.gamss.android.data.remote.user

import com.gamss.android.data.remote.model.response.ApiResponse
import com.gamss.android.data.remote.user.model.request.UpdateNicknameRequest
import com.gamss.android.data.remote.user.model.response.UserInfoResponse
import retrofit2.http.Body
import retrofit2.http.DELETE
import retrofit2.http.GET
import retrofit2.http.PATCH

internal interface UserService {

@PATCH("/api/members/me/nickname")
suspend fun updateNickname(@Body request: UpdateNicknameRequest): ApiResponse<String>
suspend fun updateNickname(@Body request: UpdateNicknameRequest): ApiResponse<UserInfoResponse>

@DELETE("/api/members/me")
suspend fun secessionUser(): ApiResponse<String>
suspend fun deleteUserAccount(): ApiResponse<String>

@GET("/api/members/me")
suspend fun getUserInfo(): ApiResponse<UserInfoResponse>
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,22 @@
package com.gamss.android.data.remote.user.model.response

import com.gamss.android.domain.user.UserProfile
import kotlinx.serialization.Serializable

@Serializable
internal data class UserInfoResponse(
val id: Long,
val email: String? = null,
val nickname: String? = null,
val status: String? = null,
val createdAt: String? = null,
)

internal fun UserInfoResponse.toDomain(): UserProfile =
UserProfile(
id = id,
email = email,
nickname = nickname,
status = status,
createdAt = createdAt,
)
Original file line number Diff line number Diff line change
@@ -1,9 +1,14 @@
package com.gamss.android.data.repository

import com.gamss.android.core.common.AppResult
import com.gamss.android.core.common.mapFailure
import com.gamss.android.core.common.network.ApiException
import com.gamss.android.data.remote.user.UserService
import com.gamss.android.data.remote.user.model.request.UpdateNicknameRequest
import com.gamss.android.domain.repository.UserRepository
import com.gamss.android.data.remote.user.model.response.toDomain
import com.gamss.android.domain.user.NicknameUpdateException
import com.gamss.android.domain.user.UserProfile
import com.gamss.android.domain.user.UserRepository
import javax.inject.Inject
import javax.inject.Singleton

Expand All @@ -12,16 +17,38 @@ internal class UserRepositoryImpl @Inject constructor(
private val userService: UserService,
) : UserRepository {

override suspend fun updateNickname(nickname: String): AppResult<String> {
override suspend fun updateNickname(nickname: String): AppResult<UserProfile> {
return runCatchingApiCall {
val response = userService.updateNickname(UpdateNicknameRequest(nickname = nickname))
checkNotNull(response.data) { "No available nickname data" }
checkNotNull(response.data) { "No available nickname data" }.toDomain()
}.mapFailure {
it.toNicknameUpdateException()
}
}

override suspend fun secession(): AppResult<Unit> {
override suspend fun deleteUserAccount(): AppResult<Unit> {
return runCatchingApiCall {
userService.secessionUser()
userService.deleteUserAccount()
}
}

override suspend fun getUserInfo(): AppResult<UserProfile> {
return runCatchingApiCall {
checkNotNull(userService.getUserInfo().data) { "No available user info data" }.toDomain()
}
}
}

private fun Throwable.toNicknameUpdateException(): Throwable =
if (this is ApiException.Http) {
when (code) {
ERROR_INVALID_INPUT -> NicknameUpdateException.MissingNickname(cause = this)
ERROR_INVALID_NICKNAME -> NicknameUpdateException.InvalidNickname(cause = this)
else -> this
}
} else {
this
}

private const val ERROR_INVALID_INPUT = "INVALID_INPUT"
private const val ERROR_INVALID_NICKNAME = "INVALID_NICKNAME"
Original file line number Diff line number Diff line change
@@ -0,0 +1,119 @@
package com.gamss.android.data.repository

import com.gamss.android.core.common.AppResult
import com.gamss.android.core.common.network.ApiException
import com.gamss.android.data.remote.model.response.ApiResponse
import com.gamss.android.data.remote.user.UserService
import com.gamss.android.data.remote.user.model.request.UpdateNicknameRequest
import com.gamss.android.data.remote.user.model.response.UserInfoResponse
import com.gamss.android.domain.user.NicknameUpdateException
import io.mockk.coEvery
import io.mockk.coVerify
import io.mockk.mockk
import kotlinx.coroutines.CancellationException
import kotlinx.coroutines.test.runTest
import okhttp3.MediaType.Companion.toMediaType
import okhttp3.ResponseBody.Companion.toResponseBody
import org.junit.Assert.assertEquals
import org.junit.Assert.assertSame
import org.junit.Assert.assertTrue
import org.junit.Test
import retrofit2.HttpException
import retrofit2.Response
import java.io.IOException

class UserRepositoryImplTest {

private val userService: UserService = mockk()
private val repository = UserRepositoryImpl(userService)

@Test
fun `닉네임 변경 성공 응답을 사용자 프로필로 변환한다`() = runTest {
coEvery {
userService.updateNickname(UpdateNicknameRequest(nickname = "새닉네임"))
} returns ApiResponse(
success = true,
data = userInfoResponse(nickname = "새닉네임"),
)

val result = repository.updateNickname("새닉네임")

val profile = (result as AppResult.Success).data
assertEquals(1L, profile.id)
assertEquals("user@gamss.com", profile.email)
assertEquals("새닉네임", profile.nickname)
assertEquals("ACTIVE", profile.status)
assertEquals("2026-08-05T00:00:00Z", profile.createdAt)
coVerify(exactly = 1) {
userService.updateNickname(UpdateNicknameRequest(nickname = "새닉네임"))
}
}

@Test
fun `서버의 잘못된 입력 코드를 닉네임 누락 실패로 변환한다`() = runTest {
coEvery { userService.updateNickname(any()) } throws httpException("INVALID_INPUT")

val result = repository.updateNickname("감쓰")

assertTrue((result as AppResult.Failure).throwable is NicknameUpdateException.MissingNickname)
}

@Test
fun `서버의 잘못된 닉네임 코드를 유효하지 않은 닉네임 실패로 변환한다`() = runTest {
coEvery { userService.updateNickname(any()) } throws httpException("INVALID_NICKNAME")

val result = repository.updateNickname("금칙어")

assertTrue((result as AppResult.Failure).throwable is NicknameUpdateException.InvalidNickname)
}

@Test
fun `알 수 없는 서버 오류 코드는 HTTP 실패로 유지한다`() = runTest {
coEvery { userService.updateNickname(any()) } throws httpException("UNKNOWN_ERROR")

val result = repository.updateNickname("감쓰")

val throwable = (result as AppResult.Failure).throwable
assertTrue(throwable is ApiException.Http)
assertEquals("UNKNOWN_ERROR", (throwable as ApiException.Http).code)
}

@Test
fun `네트워크 오류는 네트워크 실패로 유지한다`() = runTest {
val cause = IOException("offline")
coEvery { userService.updateNickname(any()) } throws cause

val result = repository.updateNickname("감쓰")

val throwable = (result as AppResult.Failure).throwable
assertTrue(throwable is ApiException.Network)
assertSame(cause, throwable.cause)
}

@Test(expected = CancellationException::class)
fun `닉네임 변경 취소는 실패로 변환하지 않고 전파한다`() = runTest {
coEvery { userService.updateNickname(any()) } throws CancellationException()

repository.updateNickname("감쓰")
}

private fun httpException(code: String): HttpException {
val errorBody = """{"success":false,"error":{"code":"$code","message":"failed"}}"""
.toResponseBody("application/json".toMediaType())
return HttpException(
Response.error<ApiResponse<UserInfoResponse>>(BAD_REQUEST, errorBody),
)
}

private fun userInfoResponse(nickname: String) = UserInfoResponse(
id = 1L,
email = "user@gamss.com",
nickname = nickname,
status = "ACTIVE",
createdAt = "2026-08-05T00:00:00Z",
)

private companion object {
const val BAD_REQUEST = 400
}
}

This file was deleted.

This file was deleted.

This file was deleted.

Original file line number Diff line number Diff line change
@@ -0,0 +1,18 @@
package com.gamss.android.domain.user

import com.gamss.android.core.common.AppResult
import com.gamss.android.domain.repository.AuthRepository
import com.gamss.android.domain.usecase.NoParamUseCase
import javax.inject.Inject

class DeleteUserAccountUseCase @Inject constructor(
private val userRepository: UserRepository,
private val authRepository: AuthRepository,
) : NoParamUseCase<AppResult<Unit>> {
override suspend fun invoke(): AppResult<Unit> {
val deleteResult = userRepository.deleteUserAccount()
if (deleteResult is AppResult.Failure) return deleteResult
authRepository.logout()
return AppResult.Success(Unit)
}
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,11 @@
package com.gamss.android.domain.user

import com.gamss.android.core.common.AppResult
import com.gamss.android.domain.usecase.NoParamUseCase
import javax.inject.Inject

class GetUserInfoUseCase @Inject constructor(
private val userRepository: UserRepository,
) : NoParamUseCase<AppResult<UserProfile>> {
override suspend fun invoke(): AppResult<UserProfile> = userRepository.getUserInfo()
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,10 @@
package com.gamss.android.domain.user
Comment thread
github-advanced-security[bot] marked this conversation as resolved.
Fixed

/**
* 닉네임 검증 규칙. UseCase와 UI가 함께 참조할 수 있도록
* UpdateNicknameUseCase가 아닌 별도 객체로 분리해 둔다.
*/
object NicknamePolicy {
const val MIN_LENGTH = 2
const val MAX_LENGTH = 20
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,7 @@
package com.gamss.android.domain.user

sealed class NicknameUpdateException(cause: Throwable? = null) : RuntimeException(cause) {
class MissingNickname(cause: Throwable? = null) : NicknameUpdateException(cause)
class InvalidLength(cause: Throwable? = null) : NicknameUpdateException(cause)
class InvalidNickname(cause: Throwable? = null) : NicknameUpdateException(cause)
}
Loading
Loading