diff --git a/app/build.gradle.kts b/app/build.gradle.kts index fe367b5..3189462 100644 --- a/app/build.gradle.kts +++ b/app/build.gradle.kts @@ -88,6 +88,9 @@ dependencies { debugImplementation(libs.androidx.compose.ui.test.manifest) // Testing dependencies testImplementation(libs.junit) + testImplementation(libs.mockk) + testImplementation(libs.turbine) + testImplementation(libs.kotlinx.coroutines.test) androidTestImplementation(libs.androidx.junit) androidTestImplementation(libs.androidx.espresso.core) androidTestImplementation(platform(libs.androidx.compose.bom)) diff --git a/app/src/main/java/com/autoever/everp/MainActivity.kt b/app/src/main/java/com/autoever/everp/MainActivity.kt index 7855691..0657467 100644 --- a/app/src/main/java/com/autoever/everp/MainActivity.kt +++ b/app/src/main/java/com/autoever/everp/MainActivity.kt @@ -4,13 +4,21 @@ import android.os.Bundle import androidx.activity.ComponentActivity import androidx.activity.compose.setContent import androidx.activity.enableEdgeToEdge +import androidx.compose.foundation.layout.Box import androidx.compose.foundation.layout.fillMaxSize import androidx.compose.material3.Surface +import androidx.compose.runtime.Composable +import androidx.compose.runtime.LaunchedEffect +import androidx.compose.runtime.getValue +import androidx.compose.runtime.mutableStateOf +import androidx.compose.runtime.remember +import androidx.compose.runtime.setValue import androidx.compose.ui.Modifier -import com.autoever.everp.ui.MainScreen import com.autoever.everp.ui.navigation.AppNavGraph +import com.autoever.everp.ui.splash.SplashScreen import com.autoever.everp.ui.theme.EverpTheme import dagger.hilt.android.AndroidEntryPoint +import kotlinx.coroutines.delay @AndroidEntryPoint class MainActivity : ComponentActivity() { @@ -20,12 +28,28 @@ class MainActivity : ComponentActivity() { enableEdgeToEdge() setContent { EverpTheme { - MainScreen() - Surface(modifier = Modifier.fillMaxSize()) { - AppNavGraph() - } + AppContent() } } } +} + +@Composable +private fun AppContent() { + var showSplash by remember { mutableStateOf(true) } + + LaunchedEffect(Unit) { + delay(2000) // 2초간 SplashScreen 표시 + showSplash = false + } + Box(modifier = Modifier.fillMaxSize()) { + if (showSplash) { + SplashScreen() + } else { + Surface(modifier = Modifier.fillMaxSize()) { + AppNavGraph() + } + } + } } diff --git a/app/src/main/java/com/autoever/everp/data/datasource/remote/FcmRemoteDataSource.kt b/app/src/main/java/com/autoever/everp/data/datasource/remote/FcmRemoteDataSource.kt index 3df361e..aa5440d 100644 --- a/app/src/main/java/com/autoever/everp/data/datasource/remote/FcmRemoteDataSource.kt +++ b/app/src/main/java/com/autoever/everp/data/datasource/remote/FcmRemoteDataSource.kt @@ -28,7 +28,7 @@ interface FcmRemoteDataSource { request: InvoiceUpdateRequestDto, ): Result - suspend fun requestReceivable( + suspend fun updateCustomerInvoiceStatus( invoiceId: String, ): Result @@ -50,7 +50,7 @@ interface FcmRemoteDataSource { request: InvoiceUpdateRequestDto, ): Result - suspend fun completeReceivable( + suspend fun updateSupplierInvoiceStatus( invoiceId: String, ): Result } diff --git a/app/src/main/java/com/autoever/everp/data/datasource/remote/http/impl/FcmHttpRemoteDataSourceImpl.kt b/app/src/main/java/com/autoever/everp/data/datasource/remote/http/impl/FcmHttpRemoteDataSourceImpl.kt index 93ba8a3..ea6f1f6 100644 --- a/app/src/main/java/com/autoever/everp/data/datasource/remote/http/impl/FcmHttpRemoteDataSourceImpl.kt +++ b/app/src/main/java/com/autoever/everp/data/datasource/remote/http/impl/FcmHttpRemoteDataSourceImpl.kt @@ -63,16 +63,14 @@ class FcmHttpRemoteDataSourceImpl @Inject constructor( } /** - * 공급사(Supplier) - * 매입 전표 상태 수정 - * 확인 요청 -> 완납 + * 매입 전표 수정 */ override suspend fun updateApInvoice( invoiceId: String, request: InvoiceUpdateRequestDto, ): Result = withContext(Dispatchers.IO) { try { - val response = fcmApi.updateApInvoice(invoiceId) + val response = fcmApi.updateApInvoice(invoiceId, request) if (response.success) { Result.success(Unit) } else { @@ -84,22 +82,6 @@ class FcmHttpRemoteDataSourceImpl @Inject constructor( } } - override suspend fun requestReceivable( - invoiceId: String, - ): Result = withContext(Dispatchers.IO) { - try { - val response = fcmApi.requestReceivable(invoiceId) - if (response.success) { - Result.success(Unit) - } else { - Result.failure(Exception(response.message ?: "수취 요청 실패")) - } - } catch (e: Exception) { - Timber.e(e, "수취 요청 실패") - Result.failure(e) - } - } - // ========== AR 인보이스 (매출) ========== override suspend fun getArInvoiceList( // companyName: String?, @@ -143,10 +125,9 @@ class FcmHttpRemoteDataSourceImpl @Inject constructor( } } + /** - * 고객사(Customer) - * 매출 전표 상태 수정 - * 미납 -> 확인 요청 + * 매출 전표 수정 */ override suspend fun updateArInvoice( invoiceId: String, @@ -165,9 +146,37 @@ class FcmHttpRemoteDataSourceImpl @Inject constructor( } } - override suspend fun completeReceivable(invoiceId: String): Result { + // ========== 인보이스 상태 수정 ========== + + /** + * 고객사(Customer) + * 매출 전표 상태 수정 + * 미납 -> 확인 요청 + */ + override suspend fun updateCustomerInvoiceStatus( + invoiceId: String, + ): Result = withContext(Dispatchers.IO) { + try { + val response = fcmApi.updateCustomerInvoiceStatus(invoiceId) + if (response.success) { + Result.success(Unit) + } else { + Result.failure(Exception(response.message)) + } + } catch (e: Exception) { + Timber.e(e, "수취 요청 실패") + Result.failure(e) + } + } + + /** + * 공급사(Supplier) + * 매입 전표 상태 수정 + * 확인 요청 -> 완납 + */ + override suspend fun updateSupplierInvoiceStatus(invoiceId: String): Result { return try { - val response = fcmApi.completeReceivable(invoiceId) + val response = fcmApi.updateSupplierInvoiceStatus(invoiceId) if (response.success) { Result.success(Unit) } else { diff --git a/app/src/main/java/com/autoever/everp/data/datasource/remote/http/service/FcmApi.kt b/app/src/main/java/com/autoever/everp/data/datasource/remote/http/service/FcmApi.kt index 4312b4a..0d6f2ad 100644 --- a/app/src/main/java/com/autoever/everp/data/datasource/remote/http/service/FcmApi.kt +++ b/app/src/main/java/com/autoever/everp/data/datasource/remote/http/service/FcmApi.kt @@ -37,14 +37,12 @@ interface FcmApi { ): ApiResponse /** - * 공급사(Supplier) - * 매입 전표 상태 수정 - * 확인 요청 -> 완납 + * */ @PATCH("$BASE_URL/invoice/ap/{invoiceId}") suspend fun updateApInvoice( @Path("invoiceId") invoiceId: String, -// @Body request: InvoiceUpdateRequestDto, + @Body request: InvoiceUpdateRequestDto, ): ApiResponse /** @@ -86,6 +84,24 @@ interface FcmApi { @Path("invoiceId") invoiceId: String, ): ApiResponse + /** + * 공급사(Supplier) + * 매출 전표 상태 수정(확인 요청 -> 완료) + */ + @POST("$BASE_URL/invoice/ap/{invoiceId}/payable/complete") + suspend fun updateSupplierInvoiceStatus( + @Path("invoiceId") invoiceId: String, + ): ApiResponse + + /** + * 고객사(Customer) + * 매입 전표 상태 수정(미납 -> 확인 요청) + */ + @POST("$BASE_URL/invoice/ap/receivable/request") + suspend fun updateCustomerInvoiceStatus( + @Query("invoiceId") invoiceId: String, + ): ApiResponse + companion object { private const val BASE_URL = "business/fcm" } diff --git a/app/src/main/java/com/autoever/everp/data/datasource/remote/http/service/SdApi.kt b/app/src/main/java/com/autoever/everp/data/datasource/remote/http/service/SdApi.kt index 7997646..f54e713 100644 --- a/app/src/main/java/com/autoever/everp/data/datasource/remote/http/service/SdApi.kt +++ b/app/src/main/java/com/autoever/everp/data/datasource/remote/http/service/SdApi.kt @@ -139,7 +139,7 @@ data class QuotationDetailResponseDto( val quotationDate: LocalDate, @Serializable(with = LocalDateSerializer::class) @SerialName("dueDate") - val dueDate: LocalDate, + val dueDate: LocalDate? = null, @SerialName("statusCode") val statusCode: QuotationStatusEnum, @SerialName("customerName") diff --git a/app/src/main/java/com/autoever/everp/data/repository/FcmRepositoryImpl.kt b/app/src/main/java/com/autoever/everp/data/repository/FcmRepositoryImpl.kt index 2796bcc..65dbe30 100644 --- a/app/src/main/java/com/autoever/everp/data/repository/FcmRepositoryImpl.kt +++ b/app/src/main/java/com/autoever/everp/data/repository/FcmRepositoryImpl.kt @@ -77,8 +77,8 @@ class FcmRepositoryImpl @Inject constructor( } } - override suspend fun requestReceivable(invoiceId: String): Result { - return fcmFinanceRemoteDataSource.requestReceivable(invoiceId) + override suspend fun updateCustomerInvoiceStatus(invoiceId: String): Result { + return fcmFinanceRemoteDataSource.updateCustomerInvoiceStatus(invoiceId) } // ========== AR 인보이스 (매출) ========== @@ -135,8 +135,8 @@ class FcmRepositoryImpl @Inject constructor( } } - override suspend fun completeReceivable(invoiceId: String): Result { - return fcmFinanceRemoteDataSource.completeReceivable(invoiceId) + override suspend fun updateSupplierInvoiceStatus(invoiceId: String): Result { + return fcmFinanceRemoteDataSource.updateSupplierInvoiceStatus(invoiceId) .onSuccess { // 완료 성공 시 로컬 캐시 갱신 refreshArInvoiceDetail(invoiceId) diff --git a/app/src/main/java/com/autoever/everp/domain/model/quotation/QuotationDetail.kt b/app/src/main/java/com/autoever/everp/domain/model/quotation/QuotationDetail.kt index cbc73cb..9fcd2fc 100644 --- a/app/src/main/java/com/autoever/everp/domain/model/quotation/QuotationDetail.kt +++ b/app/src/main/java/com/autoever/everp/domain/model/quotation/QuotationDetail.kt @@ -6,7 +6,7 @@ data class QuotationDetail( val id: String, // 견적서 ID val number: String, // 견적서 코드 val issueDate: LocalDate, // 발행일, 견적일자 - val dueDate: LocalDate, // 납기일 + val dueDate: LocalDate? = null, // 납기일 val status: QuotationStatusEnum, // 상태 값은 Enum으로 따로 관리 val totalAmount: Long, // 총 금액 val customer: QuotationDetailCustomer, diff --git a/app/src/main/java/com/autoever/everp/domain/repository/FcmRepository.kt b/app/src/main/java/com/autoever/everp/domain/repository/FcmRepository.kt index 9ddfc82..34bf125 100644 --- a/app/src/main/java/com/autoever/everp/domain/repository/FcmRepository.kt +++ b/app/src/main/java/com/autoever/everp/domain/repository/FcmRepository.kt @@ -21,7 +21,7 @@ interface FcmRepository { suspend fun getApInvoiceDetail(invoiceId: String): Result suspend fun updateApInvoice(invoiceId: String, request: InvoiceUpdateRequestDto): Result - suspend fun requestReceivable(invoiceId: String): Result + suspend fun updateCustomerInvoiceStatus(invoiceId: String): Result // ========== AR 인보이스 (매출) ========== fun observeArInvoiceList(): Flow> @@ -33,6 +33,6 @@ interface FcmRepository { suspend fun getArInvoiceDetail(invoiceId: String): Result suspend fun updateArInvoice(invoiceId: String, request: InvoiceUpdateRequestDto): Result - suspend fun completeReceivable(invoiceId: String): Result + suspend fun updateSupplierInvoiceStatus(invoiceId: String): Result } diff --git a/app/src/main/java/com/autoever/everp/ui/customer/CustomerProfileScreen.kt b/app/src/main/java/com/autoever/everp/ui/customer/CustomerProfileScreen.kt index a247269..25d66dc 100644 --- a/app/src/main/java/com/autoever/everp/ui/customer/CustomerProfileScreen.kt +++ b/app/src/main/java/com/autoever/everp/ui/customer/CustomerProfileScreen.kt @@ -13,7 +13,9 @@ import androidx.compose.foundation.shape.CircleShape import androidx.compose.foundation.verticalScroll import androidx.compose.material.icons.Icons import androidx.compose.material.icons.filled.Person +import androidx.compose.material.icons.outlined.Logout import androidx.compose.material3.Button +import androidx.compose.material3.ButtonDefaults import androidx.compose.material3.Card import androidx.compose.material3.CardDefaults import androidx.compose.material3.Icon @@ -180,7 +182,19 @@ fun CustomerProfileScreen( modifier = Modifier .fillMaxWidth() .padding(vertical = 24.dp), - ) { } + colors = ButtonDefaults.buttonColors( + containerColor = MaterialTheme.colorScheme.error, + contentColor = MaterialTheme.colorScheme.onError, + ), + shape = MaterialTheme.shapes.large, + ) { + Icon( + imageVector = Icons.Outlined.Logout, + contentDescription = "로그아웃", + ) + androidx.compose.foundation.layout.Spacer(modifier = androidx.compose.ui.Modifier.size(8.dp)) + Text(text = "로그아웃") + } } } diff --git a/app/src/main/java/com/autoever/everp/ui/customer/InvoiceDetailScreen.kt b/app/src/main/java/com/autoever/everp/ui/customer/InvoiceDetailScreen.kt index 0233993..9d403b9 100644 --- a/app/src/main/java/com/autoever/everp/ui/customer/InvoiceDetailScreen.kt +++ b/app/src/main/java/com/autoever/everp/ui/customer/InvoiceDetailScreen.kt @@ -37,9 +37,7 @@ import androidx.hilt.navigation.compose.hiltViewModel import androidx.navigation.NavController import com.autoever.everp.ui.common.components.StatusBadge import com.autoever.everp.utils.state.UiResult -import java.text.NumberFormat import java.time.format.DateTimeFormatter -import java.util.Locale @Composable fun InvoiceDetailScreen( @@ -261,10 +259,11 @@ fun InvoiceDetailScreen( } // 납부 확인 요청 버튼 (UNPAID 상태일 때만 표시) +// if (detail.status == InvoiceStatusEnum.UNPAID) { if (!isAp && detail.status == InvoiceStatusEnum.UNPAID) { Spacer(modifier = Modifier.height(16.dp)) Button( - onClick = { viewModel.requestReceivable(invoiceId) }, + onClick = { viewModel.updateCustomerInvoiceStatus(invoiceId) }, modifier = Modifier.fillMaxWidth(), ) { Text("납부 확인 요청") diff --git a/app/src/main/java/com/autoever/everp/ui/customer/InvoiceDetailViewModel.kt b/app/src/main/java/com/autoever/everp/ui/customer/InvoiceDetailViewModel.kt index 3d12474..74b8749 100644 --- a/app/src/main/java/com/autoever/everp/ui/customer/InvoiceDetailViewModel.kt +++ b/app/src/main/java/com/autoever/everp/ui/customer/InvoiceDetailViewModel.kt @@ -67,9 +67,9 @@ class InvoiceDetailViewModel @Inject constructor( loadInvoiceDetail(invoiceId, isAp) } - fun requestReceivable(invoiceId: String) { + fun updateCustomerInvoiceStatus(invoiceId: String) { viewModelScope.launch { - _requestResult.value = fcmRepository.requestReceivable(invoiceId) + _requestResult.value = fcmRepository.updateCustomerInvoiceStatus(invoiceId) .onSuccess { // 성공 시 상세 정보 다시 로드 loadInvoiceDetail(invoiceId, false) // AR 인보이스 diff --git a/app/src/main/java/com/autoever/everp/ui/customer/NotificationScreen.kt b/app/src/main/java/com/autoever/everp/ui/customer/NotificationScreen.kt index 0495ece..836fd14 100644 --- a/app/src/main/java/com/autoever/everp/ui/customer/NotificationScreen.kt +++ b/app/src/main/java/com/autoever/everp/ui/customer/NotificationScreen.kt @@ -34,6 +34,7 @@ import androidx.lifecycle.compose.collectAsStateWithLifecycle import androidx.navigation.NavController import com.autoever.everp.domain.model.notification.Notification import com.autoever.everp.domain.model.notification.NotificationLinkEnum +import com.autoever.everp.domain.model.notification.NotificationSourceEnum import com.autoever.everp.ui.common.components.StatusBadge import com.autoever.everp.ui.supplier.SupplierSubNavigationItem import java.time.Duration @@ -172,10 +173,12 @@ private fun NotificationItem( fontWeight = if (notification.isRead) FontWeight.Normal else FontWeight.Bold, ) - StatusBadge( - text = notification.source.toKorean(), - color = notification.source.toColor(), - ) + if (notification.source != NotificationSourceEnum.UNKNOWN) { + StatusBadge( + text = notification.source.toKorean(), + color = notification.source.toColor(), + ) + } } Text( diff --git a/app/src/main/java/com/autoever/everp/ui/customer/QuotationDetailScreen.kt b/app/src/main/java/com/autoever/everp/ui/customer/QuotationDetailScreen.kt index 6e02e8d..5189d4a 100644 --- a/app/src/main/java/com/autoever/everp/ui/customer/QuotationDetailScreen.kt +++ b/app/src/main/java/com/autoever/everp/ui/customer/QuotationDetailScreen.kt @@ -149,7 +149,7 @@ fun QuotationDetailScreen( ) DetailRow( label = "납기일자", - value = detail.dueDate.format(DateTimeFormatter.ofPattern("yyyy-MM-dd")), + value = detail.dueDate?.format(DateTimeFormatter.ofPattern("yyyy-MM-dd")) ?: "-", ) DetailRow( label = "총 금액", diff --git a/app/src/main/java/com/autoever/everp/ui/main/MainScreen.kt b/app/src/main/java/com/autoever/everp/ui/main/MainScreen.kt index 639d6e1..dfda69e 100644 --- a/app/src/main/java/com/autoever/everp/ui/main/MainScreen.kt +++ b/app/src/main/java/com/autoever/everp/ui/main/MainScreen.kt @@ -69,6 +69,8 @@ fun MainScreen( popUpTo(Routes.HOME) { inclusive = true } launchSingleTop = true } + } else { + homeVm.refreshUserIfAuthenticated() } } .collect() diff --git a/app/src/main/java/com/autoever/everp/ui/splash/SplashScreen.kt b/app/src/main/java/com/autoever/everp/ui/splash/SplashScreen.kt new file mode 100644 index 0000000..6cbbf00 --- /dev/null +++ b/app/src/main/java/com/autoever/everp/ui/splash/SplashScreen.kt @@ -0,0 +1,34 @@ +package com.autoever.everp.ui.splash + +import androidx.compose.foundation.Image +import androidx.compose.foundation.background +import androidx.compose.foundation.layout.Box +import androidx.compose.foundation.layout.fillMaxSize +import androidx.compose.foundation.layout.size +import androidx.compose.material3.MaterialTheme +import androidx.compose.runtime.Composable +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.graphics.Color +import androidx.compose.ui.layout.ContentScale +import androidx.compose.ui.res.painterResource +import androidx.compose.ui.unit.dp +import com.autoever.everp.R + +@Composable +fun SplashScreen() { + Box( + modifier = Modifier + .fillMaxSize() + .background(Color.White), + contentAlignment = Alignment.Center, + ) { + Image( + painter = painterResource(id = R.drawable.everp_logo), + contentDescription = "EvERP 로고", + modifier = Modifier.size(200.dp), + contentScale = ContentScale.Fit, + ) + } +} + diff --git a/app/src/main/java/com/autoever/everp/ui/supplier/InvoiceDetailScreen.kt b/app/src/main/java/com/autoever/everp/ui/supplier/InvoiceDetailScreen.kt index 20d40de..06e4706 100644 --- a/app/src/main/java/com/autoever/everp/ui/supplier/InvoiceDetailScreen.kt +++ b/app/src/main/java/com/autoever/everp/ui/supplier/InvoiceDetailScreen.kt @@ -292,10 +292,11 @@ fun InvoiceDetailScreen( } // 납부 확인 버튼 (PENDING 상태일 때만 표시) - if (!isAp && detail.status == InvoiceStatusEnum.PENDING) { +// if (detail.status == InvoiceStatusEnum.PENDING) { + if (isAp && detail.status == InvoiceStatusEnum.PENDING) { Spacer(modifier = Modifier.height(16.dp)) Button( - onClick = { viewModel.completeReceivable() }, + onClick = { viewModel.updateSupplierInvoiceStatus() }, modifier = Modifier.fillMaxWidth(), ) { Text("납부 확인") diff --git a/app/src/main/java/com/autoever/everp/ui/supplier/InvoiceDetailViewModel.kt b/app/src/main/java/com/autoever/everp/ui/supplier/InvoiceDetailViewModel.kt index f982e80..d2a6f13 100644 --- a/app/src/main/java/com/autoever/everp/ui/supplier/InvoiceDetailViewModel.kt +++ b/app/src/main/java/com/autoever/everp/ui/supplier/InvoiceDetailViewModel.kt @@ -86,9 +86,9 @@ class InvoiceDetailViewModel @Inject constructor( } } - fun completeReceivable() { + fun updateSupplierInvoiceStatus() { viewModelScope.launch { - _completeResult.value = fcmRepository.completeReceivable(invoiceId) + _completeResult.value = fcmRepository.updateSupplierInvoiceStatus(invoiceId) .onSuccess { // 성공 시 상세 정보 다시 로드 loadInvoiceDetail() diff --git a/app/src/main/java/com/autoever/everp/ui/supplier/NotificationScreen.kt b/app/src/main/java/com/autoever/everp/ui/supplier/NotificationScreen.kt index 5832b98..7d568de 100644 --- a/app/src/main/java/com/autoever/everp/ui/supplier/NotificationScreen.kt +++ b/app/src/main/java/com/autoever/everp/ui/supplier/NotificationScreen.kt @@ -75,6 +75,7 @@ fun NotificationScreen( CircularProgressIndicator() } } + error != null -> { Box( modifier = Modifier @@ -93,6 +94,7 @@ fun NotificationScreen( } } } + notifications.content.isEmpty() -> { Box( modifier = Modifier @@ -107,6 +109,7 @@ fun NotificationScreen( ) } } + else -> { LazyColumn( modifier = Modifier @@ -153,34 +156,6 @@ private fun NotificationItem( .fillMaxWidth() .padding(16.dp), ) { - Row( - modifier = Modifier.fillMaxWidth(), - horizontalArrangement = Arrangement.SpaceBetween, - verticalAlignment = Alignment.Top, - ) { - Column( - modifier = Modifier.weight(1f), - ) { - Text( - text = notification.title, - style = MaterialTheme.typography.titleMedium, - fontWeight = if (notification.isRead) FontWeight.Normal else FontWeight.Bold, - ) - Text( - text = notification.message, - style = MaterialTheme.typography.bodyMedium, - modifier = Modifier.padding(top = 4.dp), - color = MaterialTheme.colorScheme.onSurfaceVariant, - ) - } - if (!notification.isRead) { - StatusBadge( - text = "읽지 않음", - color = MaterialTheme.colorScheme.primary, - modifier = Modifier.padding(start = 8.dp), - ) - } - } Row( modifier = Modifier .fillMaxWidth() @@ -188,15 +163,28 @@ private fun NotificationItem( horizontalArrangement = Arrangement.SpaceBetween, ) { Text( - text = formatRelativeTime(notification.createdAt), - style = MaterialTheme.typography.bodySmall, - color = MaterialTheme.colorScheme.onSurfaceVariant, + text = notification.title, + style = MaterialTheme.typography.titleMedium, + fontWeight = if (notification.isRead) FontWeight.Normal else FontWeight.Bold, ) + StatusBadge( text = notification.source.toKorean(), - color = MaterialTheme.colorScheme.secondary, + color = notification.source.toColor(), ) } + + Text( + text = notification.message, + style = MaterialTheme.typography.bodyMedium, + modifier = Modifier.padding(top = 4.dp), + color = MaterialTheme.colorScheme.onSurfaceVariant, + ) + Text( + text = formatRelativeTime(notification.createdAt), + style = MaterialTheme.typography.bodySmall, + color = MaterialTheme.colorScheme.onSurfaceVariant, + ) } } } @@ -215,6 +203,7 @@ private fun navigateToDetail( SupplierSubNavigationItem.PurchaseOrderDetailItem.createRoute(notification.linkId), ) } + NotificationLinkEnum.PURCHASE_INVOICE -> { navController.navigate( SupplierSubNavigationItem.InvoiceDetailItem.createRoute( @@ -223,6 +212,7 @@ private fun navigateToDetail( ), ) } + else -> { // Supplier 화면에서는 발주와 매입 전표만 이동 } @@ -237,18 +227,23 @@ private fun formatRelativeTime(createdAt: LocalDateTime): String { duration.toSeconds() < 60 -> { "${duration.toSeconds()}초 전" } + duration.toMinutes() < 60 -> { "${duration.toMinutes()}분 전" } + duration.toHours() < 24 -> { "${duration.toHours()}시간 전" } + duration.toDays() < 30 -> { "${duration.toDays()}일 전" } + ChronoUnit.MONTHS.between(createdAt, now) < 12 -> { "${ChronoUnit.MONTHS.between(createdAt, now)}개월 전" } + else -> { "${ChronoUnit.YEARS.between(createdAt, now)}년 전" } diff --git a/app/src/main/java/com/autoever/everp/ui/supplier/SupplierHomeScreen.kt b/app/src/main/java/com/autoever/everp/ui/supplier/SupplierHomeScreen.kt index c5e9e9e..21eb9c7 100644 --- a/app/src/main/java/com/autoever/everp/ui/supplier/SupplierHomeScreen.kt +++ b/app/src/main/java/com/autoever/everp/ui/supplier/SupplierHomeScreen.kt @@ -18,6 +18,7 @@ import androidx.compose.material.icons.filled.ArrowForward import androidx.compose.material.icons.filled.Notifications import androidx.compose.material3.Card import androidx.compose.material3.CardDefaults +import androidx.compose.material3.CircularProgressIndicator import androidx.compose.material3.ExperimentalMaterial3Api import androidx.compose.material3.Icon import androidx.compose.material3.IconButton @@ -157,7 +158,7 @@ fun SupplierHomeScreen( if (isLoading) { item { - Text(text = "로딩 중...") + CircularProgressIndicator() } } else { recentActivities.forEach { activity -> diff --git a/app/src/main/java/com/autoever/everp/ui/supplier/SupplierProfileScreen.kt b/app/src/main/java/com/autoever/everp/ui/supplier/SupplierProfileScreen.kt index 82925a5..696905a 100644 --- a/app/src/main/java/com/autoever/everp/ui/supplier/SupplierProfileScreen.kt +++ b/app/src/main/java/com/autoever/everp/ui/supplier/SupplierProfileScreen.kt @@ -13,7 +13,9 @@ import androidx.compose.foundation.shape.CircleShape import androidx.compose.foundation.verticalScroll import androidx.compose.material.icons.Icons import androidx.compose.material.icons.filled.Person +import androidx.compose.material.icons.outlined.Logout import androidx.compose.material3.Button +import androidx.compose.material3.ButtonDefaults import androidx.compose.material3.Card import androidx.compose.material3.CardDefaults import androidx.compose.material3.Icon @@ -181,7 +183,19 @@ fun SupplierProfileScreen( modifier = Modifier .fillMaxWidth() .padding(vertical = 24.dp), - ) { } + colors = ButtonDefaults.buttonColors( + containerColor = MaterialTheme.colorScheme.error, + contentColor = MaterialTheme.colorScheme.onError, + ), + shape = MaterialTheme.shapes.large, + ) { + Icon( + imageVector = Icons.Outlined.Logout, + contentDescription = "로그아웃", + ) + androidx.compose.foundation.layout.Spacer(modifier = androidx.compose.ui.Modifier.size(8.dp)) + Text(text = "로그아웃") + } } } diff --git a/app/src/main/res/mipmap-anydpi/ic_launcher.xml b/app/src/main/res/mipmap-anydpi/ic_launcher.xml index 6f3b755..960519d 100644 --- a/app/src/main/res/mipmap-anydpi/ic_launcher.xml +++ b/app/src/main/res/mipmap-anydpi/ic_launcher.xml @@ -1,6 +1,6 @@ - - - + + + \ No newline at end of file diff --git a/app/src/main/res/mipmap-anydpi/ic_launcher_round.xml b/app/src/main/res/mipmap-anydpi/ic_launcher_round.xml index 6f3b755..960519d 100644 --- a/app/src/main/res/mipmap-anydpi/ic_launcher_round.xml +++ b/app/src/main/res/mipmap-anydpi/ic_launcher_round.xml @@ -1,6 +1,6 @@ - - - + + + \ No newline at end of file diff --git a/app/src/test/README.md b/app/src/test/README.md new file mode 100644 index 0000000..1f58223 --- /dev/null +++ b/app/src/test/README.md @@ -0,0 +1,81 @@ +# 테스트 코드 가이드 + +이 디렉토리에는 프로젝트의 단위 테스트 코드가 포함되어 있습니다. + +## 테스트 구조 + +### ViewModel 테스트 + +- `ui/MainViewModelTest.kt` - MainViewModel 테스트 +- `ui/customer/CustomerHomeViewModelTest.kt` - CustomerHomeViewModel 테스트 +- `ui/customer/NotificationViewModelTest.kt` - NotificationViewModel 테스트 + +### Repository 테스트 + +- `data/repository/AlarmRepositoryImplTest.kt` - AlarmRepositoryImpl 테스트 + +### Domain Model 테스트 + +- `domain/model/user/UserTypeEnumTest.kt` - UserTypeEnum 테스트 +- `domain/model/notification/NotificationTest.kt` - Notification 및 NotificationCount 테스트 + +## 테스트 실행 방법 + +### 모든 테스트 실행 + +```bash +./gradlew test +``` + +### 특정 테스트 클래스 실행 + +```bash +./gradlew test --tests "com.autoever.everp.ui.MainViewModelTest" +``` + +### 특정 테스트 메서드 실행 + +```bash +./gradlew test --tests "com.autoever.everp.ui.MainViewModelTest.초기 상태는 UNKNOWN이어야 함" +``` + +### 테스트 리포트 확인 + +테스트 실행 후 리포트는 다음 위치에서 확인할 수 있습니다: + +``` +app/build/reports/tests/test/index.html +``` + +## 사용된 테스트 라이브러리 + +- **JUnit 4** - 기본 테스트 프레임워크 +- **MockK** - Mocking 라이브러리 +- **Turbine** - Flow 테스트를 위한 라이브러리 +- **Kotlinx Coroutines Test** - 코루틴 테스트 지원 + +## 테스트 작성 가이드 + +### ViewModel 테스트 작성 시 + +1. MockK를 사용하여 Repository를 모킹합니다 +2. `runTest`를 사용하여 코루틴 테스트를 작성합니다 +3. `advanceUntilIdle()`을 사용하여 비동기 작업이 완료될 때까지 대기합니다 +4. StateFlow의 값을 검증합니다 + +### Repository 테스트 작성 시 + +1. LocalDataSource와 RemoteDataSource를 모킹합니다 +2. Flow 테스트는 Turbine을 사용합니다 +3. `runTest`를 사용하여 suspend 함수를 테스트합니다 + +### Domain Model 테스트 작성 시 + +1. 순수 함수이므로 모킹 없이 직접 테스트합니다 +2. 모든 enum 값과 경계 케이스를 테스트합니다 + +## 향후 추가 예정 테스트 + +- DataSource 테스트 +- UI 테스트 (Compose Test) +- 통합 테스트 diff --git a/app/src/test/java/com/autoever/everp/data/repository/AlarmRepositoryImplTest.kt b/app/src/test/java/com/autoever/everp/data/repository/AlarmRepositoryImplTest.kt new file mode 100644 index 0000000..370cd16 --- /dev/null +++ b/app/src/test/java/com/autoever/everp/data/repository/AlarmRepositoryImplTest.kt @@ -0,0 +1,274 @@ +package com.autoever.everp.data.repository + +import app.cash.turbine.test +import com.autoever.everp.data.datasource.local.AlarmLocalDataSource +import com.autoever.everp.data.datasource.remote.AlarmRemoteDataSource +import com.autoever.everp.data.datasource.remote.dto.common.PageResponse +import com.autoever.everp.data.datasource.remote.http.service.NotificationCountResponseDto +import com.autoever.everp.data.datasource.remote.http.service.NotificationListItemDto +import com.autoever.everp.data.datasource.remote.http.service.NotificationReadResponseDto +import com.autoever.everp.data.datasource.remote.mapper.NotificationMapper +import com.autoever.everp.domain.model.notification.Notification +import com.autoever.everp.domain.model.notification.NotificationCount +import com.autoever.everp.domain.model.notification.NotificationLinkEnum +import com.autoever.everp.domain.model.notification.NotificationListParams +import com.autoever.everp.domain.model.notification.NotificationSourceEnum +import com.autoever.everp.domain.model.notification.NotificationStatusEnum +import io.mockk.coEvery +import io.mockk.every +import io.mockk.mockk +import kotlinx.coroutines.flow.MutableStateFlow +import kotlinx.coroutines.flow.flowOf +import kotlinx.coroutines.runBlocking +import kotlinx.coroutines.test.runTest +import org.junit.Assert.assertEquals +import org.junit.Assert.assertTrue +import org.junit.Before +import org.junit.Test +import java.time.LocalDateTime + +/** + * AlarmRepositoryImpl 테스트 + */ +class AlarmRepositoryImplTest { + + private lateinit var repository: AlarmRepositoryImpl + private val localDataSource: AlarmLocalDataSource = mockk() + private val remoteDataSource: AlarmRemoteDataSource = mockk() + + @Before + fun setUp() { + repository = AlarmRepositoryImpl(localDataSource, remoteDataSource) + } + + @Test + fun `observeNotifications는 로컬 데이터 소스의 Flow를 반환해야 함`() = runTest { + // Given + val notifications = listOf( + Notification( + id = "1", + title = "Test", + message = "Test", + linkType = com.autoever.everp.domain.model.notification.NotificationLinkEnum.QUOTATION, + linkId = "1", + source = NotificationSourceEnum.SD, + status = NotificationStatusEnum.UNREAD, + createdAt = LocalDateTime.now(), + ), + ) + val pageResponse = PageResponse( + content = notifications, + page = com.autoever.everp.data.datasource.remote.dto.common.PageDto( + number = 0, + size = 20, + totalElements = 1, + totalPages = 1, + hasNext = false + ), + ) + every { localDataSource.observeNotifications() } returns MutableStateFlow(pageResponse) + + // When & Then + repository.observeNotifications().test { + val item = awaitItem() + assertEquals(1, item.content.size) + } + } + + @Test + fun `refreshNotifications 성공 시 로컬 데이터 소스에 저장되어야 함`() = runTest { + // Given + val params = NotificationListParams( + sortBy = "createdAt", + order = "desc", + source = NotificationSourceEnum.UNKNOWN, + page = 0, + size = 20, + ) + val dtoNotifications = listOf( + NotificationListItemDto( + notificationId = "1", + title = "Test", + message = "Test", + linkType = NotificationLinkEnum.QUOTATION, + linkId = "1", + source = "SD", + isRead = false, + createdAt = LocalDateTime.now().toString(), + ), + ) + val dtoPageResponse = PageResponse( + content = dtoNotifications, + page = com.autoever.everp.data.datasource.remote.dto.common.PageDto( + number = 0, + size = 20, + totalElements = 1, + totalPages = 1, + hasNext = false + ), + ) + val domainNotifications = NotificationMapper.toDomainList(dtoNotifications) + val domainPageResponse = PageResponse( + content = domainNotifications, + page = dtoPageResponse.page, + ) + + coEvery { + remoteDataSource.getNotificationList( + sortBy = params.sortBy, + order = params.order, + source = params.source, + page = params.page, + size = params.size, + ) + } returns Result.success(dtoPageResponse) + coEvery { localDataSource.setNotifications(any()) } returns Unit + + // When + val result = repository.refreshNotifications(params) + + // Then + assertTrue(result.isSuccess) + runBlocking { + localDataSource.setNotifications(domainPageResponse) + } + } + + @Test + fun `getNotificationCount 성공 시 전체와 읽지 않은 개수를 병렬로 조회해야 함`() = runTest { + // Given + val totalCountDto = NotificationCountResponseDto(count = 10) + val unreadCountDto = NotificationCountResponseDto(count = 3) + + coEvery { + remoteDataSource.getNotificationCount(status = NotificationStatusEnum.UNKNOWN) + } returns Result.success(totalCountDto) + coEvery { + remoteDataSource.getNotificationCount(status = NotificationStatusEnum.UNREAD) + } returns Result.success(unreadCountDto) + + // When + val result = repository.getNotificationCount() + + // Then + assertTrue(result.isSuccess) + val count = result.getOrNull() + assertEquals(10, count?.totalCount) + assertEquals(3, count?.unreadCount) + assertEquals(7, count?.readCount) + } + + @Test + fun `getNotificationCount 실패 시 에러를 반환해야 함`() = runTest { + // Given + coEvery { + remoteDataSource.getNotificationCount(status = NotificationStatusEnum.UNKNOWN) + } returns Result.failure(Exception("Network error")) + coEvery { + remoteDataSource.getNotificationCount(status = NotificationStatusEnum.UNREAD) + } returns Result.failure(Exception("Network error")) + + // When + val result = repository.getNotificationCount() + + // Then + assertTrue(result.isFailure) + } + + @Test + fun `refreshNotificationCount 성공 시 로컬 데이터 소스에 저장되어야 함`() = runTest { + // Given + val totalCountDto = NotificationCountResponseDto(count = 10) + val unreadCountDto = NotificationCountResponseDto(count = 3) + val expectedCount = NotificationCount( + totalCount = 10, + unreadCount = 3, + readCount = 7, + ) + + coEvery { + remoteDataSource.getNotificationCount(status = NotificationStatusEnum.UNKNOWN) + } returns Result.success(totalCountDto) + coEvery { + remoteDataSource.getNotificationCount(status = NotificationStatusEnum.UNREAD) + } returns Result.success(unreadCountDto) + coEvery { localDataSource.setNotificationCount(any()) } returns Unit + + // When + val result = repository.refreshNotificationCount() + + // Then + assertTrue(result.isSuccess) + runBlocking { + localDataSource.setNotificationCount(count = expectedCount) + } + } + + @Test + fun `observeNotificationCount는 로컬 데이터 소스의 Flow를 반환해야 함`() = runTest { + // Given + val count = NotificationCount(totalCount = 10, unreadCount = 3, readCount = 7) + every { localDataSource.observeNotificationCount() } returns MutableStateFlow(count) + + // When & Then + repository.observeNotificationCount().test { + val item = awaitItem() + assertEquals(10, item.totalCount) + assertEquals(3, item.unreadCount) + assertEquals(7, item.readCount) + } + } + + @Test + fun `markNotificationAsRead 성공 시 로컬 캐시가 업데이트되어야 함`() = runTest { + // Given + val notificationId = "notification-1" + coEvery { remoteDataSource.markNotificationAsRead(notificationId) } returns Result.success(Unit) + // updateLocalNotificationsAsRead에서 observeNotifications()를 호출하므로 mock 필요 + every { localDataSource.observeNotifications() } returns flowOf(PageResponse.empty()) + + // When + val result = repository.markNotificationAsRead(notificationId) + + // Then + assertTrue(result.isSuccess) + } + + @Test + fun `markAllNotificationsAsRead 성공 시 로컬 캐시가 업데이트되어야 함`() = runTest { + // Given + val notificationIds = listOf("1", "2", "3") + val updateResponse = NotificationReadResponseDto( + updatedCount = 3, + ) + coEvery { remoteDataSource.markNotificationsAsRead(notificationIds) } returns Result.success( + updateResponse, + ) + // updateLocalNotificationsAsRead에서 observeNotifications()를 호출하므로 mock 필요 + every { localDataSource.observeNotifications() } returns flowOf(PageResponse.empty()) + + // When + val result = repository.markNotificationsAsRead(notificationIds) + + // Then + assertTrue(result.isSuccess) + assertEquals(3, result.getOrNull()) + } + + @Test + fun `markAllNotificationsAsRead 성공 시 전체 읽음 처리되어야 함`() = runTest { + // Given + val updateResponse = NotificationReadResponseDto( + updatedCount = 5, + ) + coEvery { remoteDataSource.markAllNotificationsAsRead() } returns Result.success(updateResponse) + + // When + val result = repository.markAllNotificationsAsRead() + + // Then + assertTrue(result.isSuccess) + assertEquals(5, result.getOrNull()) + } +} + diff --git a/app/src/test/java/com/autoever/everp/domain/model/notification/NotificationTest.kt b/app/src/test/java/com/autoever/everp/domain/model/notification/NotificationTest.kt new file mode 100644 index 0000000..d136b04 --- /dev/null +++ b/app/src/test/java/com/autoever/everp/domain/model/notification/NotificationTest.kt @@ -0,0 +1,135 @@ +package com.autoever.everp.domain.model.notification + +import org.junit.Assert.assertFalse +import org.junit.Assert.assertTrue +import org.junit.Test +import java.time.LocalDateTime + +/** + * Notification Domain Model 테스트 + */ +class NotificationTest { + + @Test + fun `isRead는 READ 상태일 때 true를 반환해야 함`() { + // Given + val notification = Notification( + id = "1", + title = "Test", + message = "Test", + linkType = NotificationLinkEnum.QUOTATION, + linkId = "1", + source = NotificationSourceEnum.SD, + status = NotificationStatusEnum.READ, + createdAt = LocalDateTime.now(), + ) + + // When & Then + assertTrue(notification.isRead) + } + + @Test + fun `isRead는 UNREAD 상태일 때 false를 반환해야 함`() { + // Given + val notification = Notification( + id = "1", + title = "Test", + message = "Test", + linkType = NotificationLinkEnum.QUOTATION, + linkId = "1", + source = NotificationSourceEnum.SD, + status = NotificationStatusEnum.UNREAD, + createdAt = LocalDateTime.now(), + ) + + // When & Then + assertFalse(notification.isRead) + } + + @Test + fun `isNavigable는 linkType이 navigation을 지원하고 linkId가 있을 때 true를 반환해야 함`() { + // Given + val notification = Notification( + id = "1", + title = "Test", + message = "Test", + linkType = NotificationLinkEnum.QUOTATION, + linkId = "quote1", + source = NotificationSourceEnum.SD, + status = NotificationStatusEnum.UNREAD, + createdAt = LocalDateTime.now(), + ) + + // When & Then + assertTrue(notification.isNavigable) + } + + @Test + fun `isNavigable는 linkId가 null일 때 false를 반환해야 함`() { + // Given + val notification = Notification( + id = "1", + title = "Test", + message = "Test", + linkType = NotificationLinkEnum.QUOTATION, + linkId = null, + source = NotificationSourceEnum.SD, + status = NotificationStatusEnum.UNREAD, + createdAt = LocalDateTime.now(), + ) + + // When & Then + assertFalse(notification.isNavigable) + } +} + +/** + * NotificationCount Domain Model 테스트 + */ +class NotificationCountTest { + + @Test + fun `hasUnread는 unreadCount가 0보다 클 때 true를 반환해야 함`() { + // Given + val count = NotificationCount( + totalCount = 10, + unreadCount = 3, + readCount = 7, + ) + + // When & Then + assertTrue(count.hasUnread) + } + + @Test + fun `hasUnread는 unreadCount가 0일 때 false를 반환해야 함`() { + // Given + val count = NotificationCount( + totalCount = 10, + unreadCount = 0, + readCount = 10, + ) + + // When & Then + assertFalse(count.hasUnread) + } + + @Test + fun `NotificationCount는 totalCount와 unreadCount, readCount의 합이 일치해야 함`() { + // Given + val totalCount = 10 + val unreadCount = 3 + val readCount = 7 + + // When + val count = NotificationCount( + totalCount = totalCount, + unreadCount = unreadCount, + readCount = readCount, + ) + + // Then + assertTrue(count.totalCount == count.unreadCount + count.readCount) + } +} + diff --git a/app/src/test/java/com/autoever/everp/domain/model/user/UserTypeEnumTest.kt b/app/src/test/java/com/autoever/everp/domain/model/user/UserTypeEnumTest.kt new file mode 100644 index 0000000..78f7ed8 --- /dev/null +++ b/app/src/test/java/com/autoever/everp/domain/model/user/UserTypeEnumTest.kt @@ -0,0 +1,167 @@ +package com.autoever.everp.domain.model.user + +import org.junit.Assert.assertEquals +import org.junit.Assert.assertFalse +import org.junit.Assert.assertTrue +import org.junit.Test + +/** + * UserTypeEnum 테스트 + */ +class UserTypeEnumTest { + + @Test + fun `toKorean는 올바른 한글 문자열을 반환해야 함`() { + assertEquals("미설정", UserTypeEnum.UNKNOWN.toKorean()) + assertEquals("고객사", UserTypeEnum.CUSTOMER.toKorean()) + assertEquals("공급사", UserTypeEnum.SUPPLIER.toKorean()) + assertEquals("내부직원", UserTypeEnum.INTERNAL.toKorean()) + } + + @Test + fun `toApiString는 올바른 API 문자열을 반환해야 함`() { + assertEquals("UNKNOWN", UserTypeEnum.UNKNOWN.toApiString()) + assertEquals("CUSTOMER", UserTypeEnum.CUSTOMER.toApiString()) + assertEquals("SUPPLIER", UserTypeEnum.SUPPLIER.toApiString()) + assertEquals("INTERNAL", UserTypeEnum.INTERNAL.toApiString()) + } + + @Test + fun `isCustomer는 CUSTOMER일 때만 true를 반환해야 함`() { + assertTrue(UserTypeEnum.CUSTOMER.isCustomer()) + assertFalse(UserTypeEnum.SUPPLIER.isCustomer()) + assertFalse(UserTypeEnum.INTERNAL.isCustomer()) + assertFalse(UserTypeEnum.UNKNOWN.isCustomer()) + } + + @Test + fun `isSupplier는 SUPPLIER일 때만 true를 반환해야 함`() { + assertTrue(UserTypeEnum.SUPPLIER.isSupplier()) + assertFalse(UserTypeEnum.CUSTOMER.isSupplier()) + assertFalse(UserTypeEnum.INTERNAL.isSupplier()) + assertFalse(UserTypeEnum.UNKNOWN.isSupplier()) + } + + @Test + fun `isInternal는 INTERNAL일 때만 true를 반환해야 함`() { + assertTrue(UserTypeEnum.INTERNAL.isInternal()) + assertFalse(UserTypeEnum.CUSTOMER.isInternal()) + assertFalse(UserTypeEnum.SUPPLIER.isInternal()) + assertFalse(UserTypeEnum.UNKNOWN.isInternal()) + } + + @Test + fun `isExternal는 CUSTOMER 또는 SUPPLIER일 때 true를 반환해야 함`() { + assertTrue(UserTypeEnum.CUSTOMER.isExternal()) + assertTrue(UserTypeEnum.SUPPLIER.isExternal()) + assertFalse(UserTypeEnum.INTERNAL.isExternal()) + assertFalse(UserTypeEnum.UNKNOWN.isExternal()) + } + + @Test + fun `isValid는 UNKNOWN이 아닐 때 true를 반환해야 함`() { + assertTrue(UserTypeEnum.CUSTOMER.isValid()) + assertTrue(UserTypeEnum.SUPPLIER.isValid()) + assertTrue(UserTypeEnum.INTERNAL.isValid()) + assertFalse(UserTypeEnum.UNKNOWN.isValid()) + } + + @Test + fun `canCreateQuotation는 CUSTOMER 또는 INTERNAL일 때 true를 반환해야 함`() { + assertTrue(UserTypeEnum.CUSTOMER.canCreateQuotation()) + assertTrue(UserTypeEnum.INTERNAL.canCreateQuotation()) + assertFalse(UserTypeEnum.SUPPLIER.canCreateQuotation()) + assertFalse(UserTypeEnum.UNKNOWN.canCreateQuotation()) + } + + @Test + fun `canViewPurchaseOrder는 SUPPLIER 또는 INTERNAL일 때 true를 반환해야 함`() { + assertTrue(UserTypeEnum.SUPPLIER.canViewPurchaseOrder()) + assertTrue(UserTypeEnum.INTERNAL.canViewPurchaseOrder()) + assertFalse(UserTypeEnum.CUSTOMER.canViewPurchaseOrder()) + assertFalse(UserTypeEnum.UNKNOWN.canViewPurchaseOrder()) + } + + @Test + fun `canViewSalesOrder는 CUSTOMER 또는 INTERNAL일 때 true를 반환해야 함`() { + assertTrue(UserTypeEnum.CUSTOMER.canViewSalesOrder()) + assertTrue(UserTypeEnum.INTERNAL.canViewSalesOrder()) + assertFalse(UserTypeEnum.SUPPLIER.canViewSalesOrder()) + assertFalse(UserTypeEnum.UNKNOWN.canViewSalesOrder()) + } + + @Test + fun `fromString는 올바른 문자열을 UserTypeEnum로 변환해야 함`() { + assertEquals(UserTypeEnum.CUSTOMER, UserTypeEnum.fromString("CUSTOMER")) + assertEquals(UserTypeEnum.SUPPLIER, UserTypeEnum.fromString("SUPPLIER")) + assertEquals(UserTypeEnum.INTERNAL, UserTypeEnum.fromString("INTERNAL")) + assertEquals(UserTypeEnum.UNKNOWN, UserTypeEnum.fromString("UNKNOWN")) + } + + @Test + fun `fromString는 대소문자 구분 없이 변환해야 함`() { + assertEquals(UserTypeEnum.CUSTOMER, UserTypeEnum.fromString("customer")) + assertEquals(UserTypeEnum.SUPPLIER, UserTypeEnum.fromString("supplier")) + assertEquals(UserTypeEnum.INTERNAL, UserTypeEnum.fromString("internal")) + } + + @Test(expected = IllegalArgumentException::class) + fun `fromString는 잘못된 문자열에 대해 예외를 발생시켜야 함`() { + UserTypeEnum.fromString("INVALID") + } + + @Test + fun `fromStringOrNull는 올바른 문자열을 UserTypeEnum로 변환해야 함`() { + assertEquals(UserTypeEnum.CUSTOMER, UserTypeEnum.fromStringOrNull("CUSTOMER")) + assertEquals(UserTypeEnum.SUPPLIER, UserTypeEnum.fromStringOrNull("SUPPLIER")) + } + + @Test + fun `fromStringOrNull는 잘못된 문자열에 대해 null을 반환해야 함`() { + assertEquals(null, UserTypeEnum.fromStringOrNull("INVALID")) + assertEquals(null, UserTypeEnum.fromStringOrNull("")) + } + + @Test + fun `fromStringOrDefault는 올바른 문자열을 UserTypeEnum로 변환해야 함`() { + assertEquals(UserTypeEnum.CUSTOMER, UserTypeEnum.fromStringOrDefault("CUSTOMER")) + assertEquals(UserTypeEnum.SUPPLIER, UserTypeEnum.fromStringOrDefault("SUPPLIER")) + } + + @Test + fun `fromStringOrDefault는 잘못된 문자열에 대해 기본값을 반환해야 함`() { + assertEquals(UserTypeEnum.UNKNOWN, UserTypeEnum.fromStringOrDefault("INVALID")) + assertEquals(UserTypeEnum.CUSTOMER, UserTypeEnum.fromStringOrDefault("INVALID", UserTypeEnum.CUSTOMER)) + } + + @Test + fun `getAllValues는 모든 enum 값을 반환해야 함`() { + val values = UserTypeEnum.getAllValues() + assertEquals(4, values.size) + assertTrue(values.contains("UNKNOWN")) + assertTrue(values.contains("CUSTOMER")) + assertTrue(values.contains("SUPPLIER")) + assertTrue(values.contains("INTERNAL")) + } + + @Test + fun `getValidTypes는 UNKNOWN을 제외한 모든 타입을 반환해야 함`() { + val validTypes = UserTypeEnum.getValidTypes() + assertEquals(3, validTypes.size) + assertTrue(validTypes.contains(UserTypeEnum.CUSTOMER)) + assertTrue(validTypes.contains(UserTypeEnum.SUPPLIER)) + assertTrue(validTypes.contains(UserTypeEnum.INTERNAL)) + assertFalse(validTypes.contains(UserTypeEnum.UNKNOWN)) + } + + @Test + fun `getExternalTypes는 CUSTOMER와 SUPPLIER만 반환해야 함`() { + val externalTypes = UserTypeEnum.getExternalTypes() + assertEquals(2, externalTypes.size) + assertTrue(externalTypes.contains(UserTypeEnum.CUSTOMER)) + assertTrue(externalTypes.contains(UserTypeEnum.SUPPLIER)) + assertFalse(externalTypes.contains(UserTypeEnum.INTERNAL)) + assertFalse(externalTypes.contains(UserTypeEnum.UNKNOWN)) + } +} + diff --git a/app/src/test/java/com/autoever/everp/ui/MainViewModelTest.kt b/app/src/test/java/com/autoever/everp/ui/MainViewModelTest.kt new file mode 100644 index 0000000..a25a900 --- /dev/null +++ b/app/src/test/java/com/autoever/everp/ui/MainViewModelTest.kt @@ -0,0 +1,112 @@ +package com.autoever.everp.ui + +import com.autoever.everp.domain.model.user.UserTypeEnum +import kotlinx.coroutines.test.runTest +import org.junit.Assert.assertEquals +import org.junit.Assert.assertFalse +import org.junit.Assert.assertTrue +import org.junit.Before +import org.junit.Test + +/** + * MainViewModel 테스트 + */ +class MainViewModelTest { + + private lateinit var viewModel: MainViewModel + + @Before + fun setUp() { + viewModel = MainViewModel() + } + + @Test + fun `초기 상태는 UNKNOWN이어야 함`() { + assertEquals(UserTypeEnum.UNKNOWN, viewModel.userRole.value) + } + + @Test + fun `setUserRole로 역할 설정 시 상태가 변경되어야 함`() { + // Given + val role = UserTypeEnum.CUSTOMER + + // When + viewModel.setUserRole(role) + + // Then + assertEquals(role, viewModel.userRole.value) + } + + @Test + fun `updateUserRole로 문자열 역할 설정 시 상태가 변경되어야 함`() { + // Given + val roleString = "SUPPLIER" + + // When + viewModel.updateUserRole(roleString) + + // Then + assertEquals(UserTypeEnum.SUPPLIER, viewModel.userRole.value) + } + + @Test + fun `updateUserRole로 잘못된 문자열 입력 시 UNKNOWN으로 설정되어야 함`() { + // Given + val invalidRoleString = "INVALID" + + // When + viewModel.updateUserRole(invalidRoleString) + + // Then + assertEquals(UserTypeEnum.UNKNOWN, viewModel.userRole.value) + } + + @Test + fun `clearUserRole 호출 시 UNKNOWN으로 초기화되어야 함`() { + // Given + viewModel.setUserRole(UserTypeEnum.CUSTOMER) + + // When + viewModel.clearUserRole() + + // Then + assertEquals(UserTypeEnum.UNKNOWN, viewModel.userRole.value) + } + + @Test + fun `isCustomer는 CUSTOMER일 때 true를 반환해야 함`() { + // Given + viewModel.setUserRole(UserTypeEnum.CUSTOMER) + + // When & Then + assertTrue(viewModel.isCustomer()) + } + + @Test + fun `isCustomer는 CUSTOMER가 아닐 때 false를 반환해야 함`() { + // Given + viewModel.setUserRole(UserTypeEnum.SUPPLIER) + + // When & Then + assertFalse(viewModel.isCustomer()) + } + + @Test + fun `isVendor는 SUPPLIER일 때 true를 반환해야 함`() { + // Given + viewModel.setUserRole(UserTypeEnum.SUPPLIER) + + // When & Then + assertTrue(viewModel.isVendor()) + } + + @Test + fun `isVendor는 SUPPLIER가 아닐 때 false를 반환해야 함`() { + // Given + viewModel.setUserRole(UserTypeEnum.CUSTOMER) + + // When & Then + assertFalse(viewModel.isVendor()) + } +} + diff --git a/gradle/libs.versions.toml b/gradle/libs.versions.toml index bcd984c..14251a6 100644 --- a/gradle/libs.versions.toml +++ b/gradle/libs.versions.toml @@ -20,6 +20,9 @@ materialIconsExtended = "1.7.8" junit = "4.13.2" junitVersion = "1.3.0" espressoCore = "3.7.0" +mockk = "1.13.10" +turbine = "1.1.0" +coroutinesTest = "1.10.2" # DI hilt = "2.57.2" hiltNavigationCompose = "1.3.0" @@ -70,6 +73,9 @@ androidx-compose-material-icons-extended = { group = "androidx.compose.material" junit = { group = "junit", name = "junit", version.ref = "junit" } androidx-junit = { group = "androidx.test.ext", name = "junit", version.ref = "junitVersion" } androidx-espresso-core = { group = "androidx.test.espresso", name = "espresso-core", version.ref = "espressoCore" } +mockk = { group = "io.mockk", name = "mockk", version.ref = "mockk" } +turbine = { group = "app.cash.turbine", name = "turbine", version.ref = "turbine" } +kotlinx-coroutines-test = { group = "org.jetbrains.kotlinx", name = "kotlinx-coroutines-test", version.ref = "coroutinesTest" } # DI Libraries hilt-android = { group = "com.google.dagger", name = "hilt-android", version.ref = "hilt" }