diff --git a/app/src/main/java/com/flint/data/dto/ott/response/OttListResponseDto.kt b/app/src/main/java/com/flint/data/dto/ott/response/OttListResponseDto.kt index 0681ff49..50b20099 100644 --- a/app/src/main/java/com/flint/data/dto/ott/response/OttListResponseDto.kt +++ b/app/src/main/java/com/flint/data/dto/ott/response/OttListResponseDto.kt @@ -17,6 +17,7 @@ data class OttItemResponseDto( val name: String, @SerialName("logoUrl") val logoUrl: String, + // 서버 응답(GetOttResponse)에 없는 필드. 기본값이 없으면 역직렬화가 실패한다 @SerialName("contentUrl") - val contentUrl: String + val contentUrl: String = "", ) \ No newline at end of file diff --git a/app/src/main/java/com/flint/data/dto/user/response/UserProfileResponseDto.kt b/app/src/main/java/com/flint/data/dto/user/response/UserProfileResponseDto.kt index 03e13910..815b3130 100644 --- a/app/src/main/java/com/flint/data/dto/user/response/UserProfileResponseDto.kt +++ b/app/src/main/java/com/flint/data/dto/user/response/UserProfileResponseDto.kt @@ -13,6 +13,9 @@ data class UserProfileResponseDto( val isFliner: Boolean, @SerialName("nickname") val nickname: String, + // 내 프로필(/users/me) 응답에만 존재, 이메일 미보유 시 null + @SerialName("email") + val email: String? = null, @SerialName("keywordRecalculatable") val keywordRecalculatable: Boolean = false, ) diff --git a/app/src/main/java/com/flint/domain/mapper/user/ProfileMapper.kt b/app/src/main/java/com/flint/domain/mapper/user/ProfileMapper.kt index 257df653..97fe4974 100644 --- a/app/src/main/java/com/flint/domain/mapper/user/ProfileMapper.kt +++ b/app/src/main/java/com/flint/domain/mapper/user/ProfileMapper.kt @@ -9,5 +9,6 @@ fun UserProfileResponseDto.toModel(): UserProfileResponseModel = isFliner = isFliner, nickname = nickname, profileImageUrl = profileImageUrl, + email = email, keywordRecalculatable = keywordRecalculatable, ) \ No newline at end of file diff --git a/app/src/main/java/com/flint/domain/model/user/UserProfileResponseModel.kt b/app/src/main/java/com/flint/domain/model/user/UserProfileResponseModel.kt index f20eef88..3f2415e2 100644 --- a/app/src/main/java/com/flint/domain/model/user/UserProfileResponseModel.kt +++ b/app/src/main/java/com/flint/domain/model/user/UserProfileResponseModel.kt @@ -5,6 +5,7 @@ data class UserProfileResponseModel( val isFliner: Boolean, val nickname: String, val profileImageUrl: String?, + val email: String? = null, val keywordRecalculatable: Boolean = false, ) { companion object { diff --git a/app/src/main/java/com/flint/presentation/home/HomeScreen.kt b/app/src/main/java/com/flint/presentation/home/HomeScreen.kt index 4394a743..2d7d97e3 100644 --- a/app/src/main/java/com/flint/presentation/home/HomeScreen.kt +++ b/app/src/main/java/com/flint/presentation/home/HomeScreen.kt @@ -85,7 +85,7 @@ fun HomeRoute( onFamousCollectionItemClick = { navigateToCollectionDetail(it) }, onFamousCollectionAllClick = { navigateToCollectionList(CollectionListRouteType.FAMOUS) }, onRecommendCollectionItemClick = { navigateToCollectionDetail(it) }, - onSavedContentItemClick = { viewModel.getOttListPerContent(it) }, + onSavedContentItemClick = { viewModel.showOttList(it) }, modifier = Modifier.padding(paddingValues), ) } diff --git a/app/src/main/java/com/flint/presentation/home/HomeViewModel.kt b/app/src/main/java/com/flint/presentation/home/HomeViewModel.kt index e2ffdf53..cbd17da6 100644 --- a/app/src/main/java/com/flint/presentation/home/HomeViewModel.kt +++ b/app/src/main/java/com/flint/presentation/home/HomeViewModel.kt @@ -7,12 +7,14 @@ import com.flint.core.common.util.UiState import com.flint.data.local.PreferencesManager import com.flint.domain.model.collection.CollectionListModel import com.flint.domain.model.content.BookmarkedContentListModel -import com.flint.domain.repository.ContentRepository +import com.flint.domain.model.ott.OttListModel +import com.flint.domain.model.ott.OttModel import com.flint.domain.repository.HomeRepository import com.flint.domain.repository.UserRepository import com.flint.presentation.home.sideeffect.HomeSideEffect import com.flint.presentation.home.uistate.HomeUiState import dagger.hilt.android.lifecycle.HiltViewModel +import kotlinx.collections.immutable.toImmutableList import kotlinx.coroutines.flow.MutableSharedFlow import kotlinx.coroutines.flow.MutableStateFlow import kotlinx.coroutines.flow.SharingStarted @@ -29,7 +31,6 @@ class HomeViewModel @Inject constructor( private val preferencesManager: PreferencesManager, private val homeRepository: HomeRepository, private val userRepository: UserRepository, - private val contentRepository: ContentRepository, ) : ViewModel() { private val _userName = preferencesManager.getString(USER_NAME) @@ -71,7 +72,18 @@ class HomeViewModel @Inject constructor( fun getBookmarkedContentList() = viewModelScope.launch { userRepository.getUserBookmarkedContents(userId = null) - .onSuccess { _bookmarkedContentListLoadState.emit(UiState.Success(it)) } + .onSuccess { bookmarkedContents -> + // 홈에서는 최근 저장한 콘텐츠 10개까지만 노출 (전체 목록은 프로필 > 저장한 콘텐츠에서 확인) + _bookmarkedContentListLoadState.emit( + UiState.Success( + bookmarkedContents.copy( + contents = bookmarkedContents.contents + .take(MAX_SAVED_CONTENT_COUNT) + .toImmutableList(), + ), + ), + ) + } .onFailure { Timber.e(it.message) } } @@ -81,9 +93,25 @@ class HomeViewModel @Inject constructor( .onFailure { Timber.e(it.message) } } - fun getOttListPerContent(contentId: String) = viewModelScope.launch { - contentRepository.getOttListPerContent(contentId) - .onSuccess { _homeSideEffect.emit(HomeSideEffect.ShowOttListBottomSheet(it)) } - .onFailure { Timber.e(it.message) } + // 콘텐츠별 OTT 목록 API(/contents/ott/{id})가 빈 배열만 반환하므로 + // 이미 로드된 북마크 목록의 OTT 정보를 사용한다. + // 프로필/저장한 콘텐츠 화면도 동일하게 getOttSimpleList를 쓴다. + fun showOttList(contentId: String) = viewModelScope.launch { + val otts = (_bookmarkedContentListLoadState.value as? UiState.Success) + ?.data + ?.contents + ?.find { it.id == contentId } + ?.getOttSimpleList + .orEmpty() + + _homeSideEffect.emit( + HomeSideEffect.ShowOttListBottomSheet( + OttListModel(otts = otts.map { OttModel(name = it.name) }), + ), + ) + } + + companion object { + private const val MAX_SAVED_CONTENT_COUNT = 10 } } \ No newline at end of file diff --git a/app/src/main/java/com/flint/presentation/onboarding/OnboardingUiState.kt b/app/src/main/java/com/flint/presentation/onboarding/OnboardingUiState.kt index fd0d5a0c..e367b9e2 100644 --- a/app/src/main/java/com/flint/presentation/onboarding/OnboardingUiState.kt +++ b/app/src/main/java/com/flint/presentation/onboarding/OnboardingUiState.kt @@ -15,7 +15,7 @@ data class OnboardingTermsUiState( enum class NicknameErrorType { DUPLICATE, // 이미 사용 중인 닉네임 - INVALID_FORMAT // 한글, 영문 외 문자 포함 + INVALID_FORMAT // 한글, 영문, 숫자 외 문자 포함 } data class OnboardingProfileUiState( @@ -29,7 +29,7 @@ data class OnboardingProfileUiState( companion object { const val MAX_LENGTH = 8 const val MIN_LENGTH = 2 - private val NICKNAME_REGEX = Regex("^[가-힣ㄱ-ㅎㅏ-ㅣa-zA-Z]+$") + private val NICKNAME_REGEX = Regex("^[가-힣ㄱ-ㅎㅏ-ㅣa-zA-Z0-9]+$") private val STANDALONE_KOREAN_REGEX = Regex("[ㄱ-ㅎㅏ-ㅣ]") fun isValidFormat(nickname: String): Boolean { diff --git a/app/src/main/java/com/flint/presentation/setting/SettingScreen.kt b/app/src/main/java/com/flint/presentation/setting/SettingScreen.kt index e02ee456..0b6e8c50 100644 --- a/app/src/main/java/com/flint/presentation/setting/SettingScreen.kt +++ b/app/src/main/java/com/flint/presentation/setting/SettingScreen.kt @@ -2,9 +2,11 @@ package com.flint.presentation.setting import androidx.compose.foundation.Image import androidx.compose.foundation.background +import androidx.compose.foundation.layout.Arrangement import androidx.compose.foundation.layout.Box import androidx.compose.foundation.layout.Column import androidx.compose.foundation.layout.Row +import androidx.compose.foundation.layout.RowScope import androidx.compose.foundation.layout.Spacer import androidx.compose.foundation.layout.fillMaxSize import androidx.compose.foundation.layout.fillMaxWidth @@ -27,6 +29,7 @@ import androidx.compose.ui.Modifier import androidx.compose.ui.platform.LocalContext import androidx.compose.ui.res.painterResource import androidx.compose.ui.text.style.TextDecoration +import androidx.compose.ui.text.style.TextOverflow import androidx.compose.ui.tooling.preview.Preview import androidx.compose.ui.unit.dp import androidx.hilt.navigation.compose.hiltViewModel @@ -111,7 +114,20 @@ private fun SettingScreen( SettingMenuItem( label = "계정", trailingContent = { - Row(verticalAlignment = Alignment.CenterVertically) { + Row( + horizontalArrangement = Arrangement.spacedBy(8.dp), + verticalAlignment = Alignment.CenterVertically, + ) { + uiState.email?.let { email -> + Text( + text = email, + style = FlintTheme.typography.body2R14, + color = FlintTheme.colors.gray100, + maxLines = 1, + overflow = TextOverflow.Ellipsis, + modifier = Modifier.weight(1f, fill = false), + ) + } Image( painter = painterResource(R.drawable.ic_kakao_full), contentDescription = null, @@ -226,7 +242,7 @@ private fun SettingMenuItem( modifier: Modifier = Modifier, verticalPadding: Dp = 18.dp, onClick: () -> Unit = {}, - trailingContent: @Composable () -> Unit = {}, + trailingContent: @Composable RowScope.() -> Unit = {}, ) { Row( modifier = modifier @@ -253,6 +269,7 @@ private fun SettingScreenPreview() { uiState = SettingUiState( nickname = "한비두비세비", profileImageUrl = null, + email = "flint@kakao.com", ), onBackClick = {}, onEditProfileClick = {}, diff --git a/app/src/main/java/com/flint/presentation/setting/SettingUiState.kt b/app/src/main/java/com/flint/presentation/setting/SettingUiState.kt index ddf077fc..80f85366 100644 --- a/app/src/main/java/com/flint/presentation/setting/SettingUiState.kt +++ b/app/src/main/java/com/flint/presentation/setting/SettingUiState.kt @@ -3,5 +3,6 @@ package com.flint.presentation.setting data class SettingUiState( val nickname: String = "", val profileImageUrl: String? = null, + val email: String? = null, val isLogoutDialogVisible: Boolean = false, ) diff --git a/app/src/main/java/com/flint/presentation/setting/SettingViewModel.kt b/app/src/main/java/com/flint/presentation/setting/SettingViewModel.kt index 6f4bc27e..e3e9a74a 100644 --- a/app/src/main/java/com/flint/presentation/setting/SettingViewModel.kt +++ b/app/src/main/java/com/flint/presentation/setting/SettingViewModel.kt @@ -40,7 +40,12 @@ class SettingViewModel @Inject constructor( viewModelScope.launch { userRepository.getUserProfile(userId = null) .onSuccess { profile -> - _uiState.update { it.copy(profileImageUrl = profile.profileImageUrl) } + _uiState.update { + it.copy( + profileImageUrl = profile.profileImageUrl, + email = profile.email, + ) + } } .onFailure { Timber.e(it) } } diff --git a/app/src/main/java/com/flint/presentation/setting/editprofile/EditProfileUiState.kt b/app/src/main/java/com/flint/presentation/setting/editprofile/EditProfileUiState.kt index ea25a6f5..96428588 100644 --- a/app/src/main/java/com/flint/presentation/setting/editprofile/EditProfileUiState.kt +++ b/app/src/main/java/com/flint/presentation/setting/editprofile/EditProfileUiState.kt @@ -15,7 +15,7 @@ data class EditProfileUiState( companion object { const val MAX_LENGTH = 8 const val MIN_LENGTH = 2 - private val NICKNAME_REGEX = Regex("^[가-힣ㄱ-ㅎㅏ-ㅣa-zA-Z]+$") + private val NICKNAME_REGEX = Regex("^[가-힣ㄱ-ㅎㅏ-ㅣa-zA-Z0-9]+$") private val STANDALONE_KOREAN_REGEX = Regex("[ㄱ-ㅎㅏ-ㅣ]") fun isValidFormat(nickname: String): Boolean = diff --git a/app/src/test/java/com/flint/data/dto/ott/OttListResponseDtoTest.kt b/app/src/test/java/com/flint/data/dto/ott/OttListResponseDtoTest.kt new file mode 100644 index 00000000..d411f53f --- /dev/null +++ b/app/src/test/java/com/flint/data/dto/ott/OttListResponseDtoTest.kt @@ -0,0 +1,87 @@ +package com.flint.data.dto.ott + +import com.flint.data.dto.base.BaseResponse +import com.flint.data.dto.ott.response.OttListResponseDto +import kotlinx.serialization.json.Json +import org.junit.Assert.assertEquals +import org.junit.Test + +/** + * 콘텐츠별 OTT 목록 응답 역직렬화 테스트 + * + * QA TC 3-33, 3-34: 홈에서 콘텐츠 카드를 눌러도 바텀시트가 뜨지 않는 문제 + * + * GET /api/v1/contents/ott/{contentId} 의 서버 응답 스키마(GetOttResponse)는 + * { ottId, name, logoUrl } 세 필드뿐이고 contentUrl 은 존재하지 않는다. + * DTO 가 contentUrl 을 기본값 없는 필수 필드로 선언하면 역직렬화가 실패하고, + * 그 예외가 suspendRunCatching -> onFailure 로 흘러가 조용히 삼켜진다. + */ +class OttListResponseDtoTest { + + // NetworkModule 의 Json 설정과 동일하게 맞춘다 + private val json = Json { + ignoreUnknownKeys = true + coerceInputValues = true + explicitNulls = false + prettyPrint = true + } + + /** 스웨거 GetOttListRes 스키마 그대로 — contentUrl 없음 */ + private val serverResponse = """ + { + "status": 200, + "message": "OTT리스트 조회 성공", + "data": { + "otts": [ + { "ottId": "1", "name": "넷플릭스", "logoUrl": "https://cdn.flint/netflix.png" }, + { "ottId": "2", "name": "티빙", "logoUrl": "https://cdn.flint/tving.png" } + ] + } + } + """.trimIndent() + + @Test + fun `contentUrl 이 없는 서버 응답을 역직렬화할 수 있다`() { + val response = json.decodeFromString>(serverResponse) + + assertEquals(2, response.data.otts.size) + assertEquals("넷플릭스", response.data.otts[0].name) + assertEquals("https://cdn.flint/tving.png", response.data.otts[1].logoUrl) + } + + @Test + fun `contentUrl 이 없으면 빈 문자열로 채운다`() { + val response = json.decodeFromString>(serverResponse) + + assertEquals("", response.data.otts[0].contentUrl) + } + + @Test + fun `서버가 contentUrl 을 내려주면 그 값을 사용한다`() { + val withContentUrl = """ + { + "otts": [ + { + "ottId": "1", + "name": "넷플릭스", + "logoUrl": "https://cdn.flint/netflix.png", + "contentUrl": "https://netflix.com/title/123" + } + ] + } + """.trimIndent() + + val dto = json.decodeFromString(withContentUrl) + + assertEquals("https://netflix.com/title/123", dto.otts[0].contentUrl) + } + + @Test + fun `볼 수 있는 OTT 가 없으면 빈 목록으로 역직렬화된다`() { + val emptyResponse = """{ "otts": [] }""" + + val dto = json.decodeFromString(emptyResponse) + + assertEquals(0, dto.otts.size) + } +} diff --git a/app/src/test/java/com/flint/presentation/onboarding/NicknameValidationTest.kt b/app/src/test/java/com/flint/presentation/onboarding/NicknameValidationTest.kt new file mode 100644 index 00000000..22d345a0 --- /dev/null +++ b/app/src/test/java/com/flint/presentation/onboarding/NicknameValidationTest.kt @@ -0,0 +1,101 @@ +package com.flint.presentation.onboarding + +import com.flint.presentation.setting.editprofile.EditProfileUiState +import org.junit.Assert.assertEquals +import org.junit.Assert.assertFalse +import org.junit.Assert.assertTrue +import org.junit.Test + +/** + * 닉네임 유효성 검사 규칙 테스트 + * + * QA TC 1-34: 한글/영문/숫자 혼합 2~8자는 정상 입력 가능해야 한다 + * QA TC 1-40: 특수문자, 기호, 이모지, 한글/영문 외 다른 언어는 거부되어야 한다 + * + * 온보딩(OnboardingProfileUiState)과 프로필 수정(EditProfileUiState)에 + * 동일한 규칙이 각각 구현돼 있어 양쪽 모두 검증한다. + */ +class NicknameValidationTest { + + private fun bothAccept(nickname: String): Boolean { + val onboarding = OnboardingProfileUiState.isValidFormat(nickname) + val editProfile = EditProfileUiState.isValidFormat(nickname) + assertEquals( + "온보딩과 프로필 수정의 판정이 달라선 안 된다: \"$nickname\"", + onboarding, + editProfile, + ) + return onboarding + } + + // ---- TC 1-34: 숫자 허용 ---- + + @Test + fun `숫자만으로 이루어진 닉네임을 허용한다`() { + assertTrue(bothAccept("1234")) + } + + @Test + fun `한글과 숫자를 섞은 닉네임을 허용한다`() { + assertTrue(bothAccept("플린트2")) + } + + @Test + fun `영문과 숫자를 섞은 닉네임을 허용한다`() { + assertTrue(bothAccept("flint2")) + } + + @Test + fun `한글 영문 숫자를 모두 섞은 닉네임을 허용한다`() { + assertTrue(bothAccept("플린트flint7")) + } + + // ---- 기존 허용 범위 회귀 ---- + + @Test + fun `한글만으로 이루어진 닉네임을 허용한다`() { + assertTrue(bothAccept("플린트")) + } + + @Test + fun `영문만으로 이루어진 닉네임을 허용한다`() { + assertTrue(bothAccept("Flint")) + } + + @Test + fun `빈 문자열은 형식 오류로 보지 않는다`() { + // 입력 전 상태에서 에러 메시지가 뜨면 안 되므로 형식 검사는 통과시킨다 + assertTrue(bothAccept("")) + } + + // ---- TC 1-40: 거부 대상 ---- + + @Test + fun `특수문자가 포함되면 거부한다`() { + assertFalse(bothAccept("플린트!")) + assertFalse(bothAccept("flint_2")) + assertFalse(bothAccept("flint 2")) + } + + @Test + fun `이모지가 포함되면 거부한다`() { + assertFalse(bothAccept("플린트🔥")) + } + + @Test + fun `한글 영문 외 다른 언어는 거부한다`() { + assertFalse(bothAccept("플린트あ")) + assertFalse(bothAccept("플린트中")) + } + + // ---- 자음/모음 단독 입력 ---- + + @Test + fun `자음이나 모음 단독 입력을 감지한다`() { + assertTrue(OnboardingProfileUiState(nickname = "ㅋㅋㅋ").hasStandaloneKorean) + assertTrue(EditProfileUiState(nickname = "ㅋㅋㅋ").hasStandaloneKorean) + + assertFalse(OnboardingProfileUiState(nickname = "플린트2").hasStandaloneKorean) + assertFalse(EditProfileUiState(nickname = "플린트2").hasStandaloneKorean) + } +}