diff --git a/.github/workflows/debug.yml b/.github/workflows/debug.yml
index 0ea4a6924..430741e3c 100644
--- a/.github/workflows/debug.yml
+++ b/.github/workflows/debug.yml
@@ -101,6 +101,73 @@ jobs:
path: app/build/outputs/apk/release/*.apk
retention-days: 1
+ screenshot-verify:
+ name: Verify Screenshot Regression
+ if: github.event_name == 'pull_request'
+ runs-on: ubuntu-latest
+ steps:
+ - uses: actions/checkout@v3
+
+ - name: Cache Gradle packages
+ uses: actions/cache@v4
+ with:
+ path: |
+ ~/.gradle/caches
+ ~/.gradle/wrapper
+ key: ${{ runner.os }}-gradle-${{ hashFiles('**/*.gradle*', '**/gradle-wrapper.properties', '**/buildSrc/**/*.kt') }}
+ restore-keys: |
+ ${{ runner.os }}-gradle-
+
+ - name: set up JDK 17
+ uses: actions/setup-java@v3
+ with:
+ java-version: '17'
+ distribution: 'temurin'
+ cache: gradle
+
+ - name: Create Local Properties
+ run: touch local.properties
+
+ - name: Access Local Properties
+ env:
+ DEV_BASE_URL: ${{ secrets.DEV_BASE_URL }}
+ PROD_BASE_URL: ${{ secrets.PROD_BASE_URL }}
+ KAKAO_NATIVE_APP_KEY: ${{ secrets.KAKAO_NATIVE_APP_KEY }}
+ NAVER_MAPS_CLIENT_ID: ${{ secrets.NAVER_MAPS_CLIENT_ID }}
+ POSTHOG_API_KEY: ${{ secrets.POSTHOG_API_KEY }}
+ POSTHOG_HOST: ${{ secrets.POSTHOG_HOST }}
+ run: |
+ echo DEV_BASE_URL=\"$DEV_BASE_URL\" >> local.properties
+ echo PROD_BASE_URL=\"$PROD_BASE_URL\" >> local.properties
+ echo KAKAO_NATIVE_APP_KEY=$KAKAO_NATIVE_APP_KEY >> local.properties
+ echo NAVER_MAPS_CLIENT_ID=$NAVER_MAPS_CLIENT_ID >> local.properties
+ echo POSTHOG_API_KEY=$POSTHOG_API_KEY >> local.properties
+ echo POSTHOG_HOST=$POSTHOG_HOST >> local.properties
+
+ - name: Generate google-services.json
+ run: |
+ echo "$GOOGLE_SERVICE" > app/google-services.json.b64
+ base64 -d -i app/google-services.json.b64 > app/google-services.json
+ env:
+ GOOGLE_SERVICE: ${{ secrets.GOOGLE_SERVICE }}
+
+ - name: Grant execute permission for gradlew
+ run: chmod +x gradlew
+
+ - name: Verify Roborazzi
+ run: ./gradlew :app:verifyRoborazziDebug
+
+ - name: Upload Roborazzi outputs (failure only)
+ if: failure()
+ uses: actions/upload-artifact@v4
+ with:
+ name: roborazzi-output
+ path: |
+ app/build/outputs/roborazzi
+ app/build/reports/roborazzi
+ app/build/test-results/roborazzi
+ retention-days: 7
+
deploy-firebase:
needs: build
name: Deploy to Firebase
diff --git a/README.md b/README.md
index cbfb0a952..d9556f15e 100644
--- a/README.md
+++ b/README.md
@@ -74,6 +74,9 @@
이 프로젝트는 fastlane을 사용하여 자동화된 빌드 및 배포를 지원합니다.
+## 🖼 Screenshot Testing
+- [Roborazzi + Robolectric 스크린샷 회귀 테스트 가이드](docs/SCREENSHOT_TESTING.md)
+
**📖 상세한 가이드는 다음 문서를 참조하세요:**
👉 **[Fastlane을 이용한 배포 총 정리](.github/FASTLANE_DEPLOYMENT_GUIDE.md)**
diff --git a/app/build.gradle.kts b/app/build.gradle.kts
index 0036a99dd..bc7654f64 100644
--- a/app/build.gradle.kts
+++ b/app/build.gradle.kts
@@ -1,9 +1,11 @@
+import com.github.takahirom.roborazzi.ExperimentalRoborazziApi
import java.util.Properties
plugins {
alias(libs.plugins.android.application)
alias(libs.plugins.kotlin.android)
alias(libs.plugins.compose.compiler)
+ alias(libs.plugins.roborazzi)
alias(libs.plugins.google.services)
alias(libs.plugins.firebase.crashlytics)
alias(libs.plugins.hilt.android)
@@ -134,6 +136,26 @@ android {
lint {
abortOnError = false
}
+
+ testOptions {
+ unitTests.isIncludeAndroidResources = true
+ unitTests.all {
+ val requestedTasks = gradle.startParameter.taskNames.joinToString(" ")
+ val runScreenshotCapture =
+ requestedTasks.contains("recordRoborazzi") || requestedTasks.contains("verifyRoborazzi")
+ it.useJUnitPlatform()
+ it.systemProperty("roborazzi.record.filePathStrategy", "relativePathFromRoborazziContextOutputDirectory")
+ it.systemProperty("eatssu.screenshot.capture", runScreenshotCapture.toString())
+ }
+ }
+}
+
+@OptIn(ExperimentalRoborazziApi::class)
+roborazzi {
+ outputDir.set(file("src/test/screenshots"))
+ compare {
+ outputDir.set(file("build/outputs/roborazzi"))
+ }
}
dependencies {
@@ -180,6 +202,18 @@ dependencies {
// Testing libraries
testImplementation(libs.junit)
+ testImplementation(libs.kotest.runner.junit5)
+ testImplementation(libs.kotest.assertions.core)
+ testImplementation(libs.kotest.property)
+ testImplementation(libs.mockk)
+ testImplementation(libs.kotlinx.coroutines.test)
+ testImplementation(libs.turbine)
+ testImplementation(libs.robolectric)
+ testImplementation(libs.roborazzi)
+ testImplementation(libs.roborazzi.compose)
+ testImplementation(libs.roborazzi.junit.rule)
+ testImplementation(libs.androidx.compose.ui.test.junit4)
+ testRuntimeOnly(libs.junit.vintage.engine)
androidTestImplementation(libs.androidx.test.ext.junit)
androidTestImplementation(libs.androidx.espresso.core)
@@ -269,4 +303,4 @@ dependencies {
configurations.all {
exclude(group = "io.github.fornewid", module = "naver-map-location")
-}
\ No newline at end of file
+}
diff --git a/app/src/main/java/com/eatssu/android/di/AppModule.kt b/app/src/main/java/com/eatssu/android/di/AppModule.kt
index 90a3b0bbe..0fe627a97 100644
--- a/app/src/main/java/com/eatssu/android/di/AppModule.kt
+++ b/app/src/main/java/com/eatssu/android/di/AppModule.kt
@@ -6,6 +6,7 @@ import dagger.Module
import dagger.Provides
import dagger.hilt.InstallIn
import dagger.hilt.components.SingletonComponent
+import java.time.Clock
import javax.inject.Singleton
@Module
@@ -17,4 +18,8 @@ object AppModule {
fun provideContext(application: Application): Context {
return application.applicationContext
}
+
+ @Provides
+ @Singleton
+ fun provideClock(): Clock = Clock.systemDefaultZone()
}
diff --git a/app/src/main/java/com/eatssu/android/domain/usecase/alarm/AlarmUsecase.kt b/app/src/main/java/com/eatssu/android/domain/usecase/alarm/AlarmUsecase.kt
index 254357aa7..ec641a3b5 100644
--- a/app/src/main/java/com/eatssu/android/domain/usecase/alarm/AlarmUsecase.kt
+++ b/app/src/main/java/com/eatssu/android/domain/usecase/alarm/AlarmUsecase.kt
@@ -7,10 +7,12 @@ import android.content.Intent
import com.eatssu.android.alarm.NotificationReceiver
import dagger.hilt.android.qualifiers.ApplicationContext
import java.util.Calendar
+import java.time.Clock
import javax.inject.Inject
class AlarmUseCase @Inject constructor(
@ApplicationContext private val context: Context,
+ private val clock: Clock,
) {
fun scheduleAlarm() {
@@ -21,13 +23,14 @@ class AlarmUseCase @Inject constructor(
)
val calendar = Calendar.getInstance().apply {
+ timeInMillis = clock.millis()
set(Calendar.HOUR_OF_DAY, 11)
set(Calendar.MINUTE, 0)
set(Calendar.SECOND, 0)
set(Calendar.MILLISECOND, 0)
}
- if (calendar.timeInMillis <= System.currentTimeMillis()) {
+ if (calendar.timeInMillis <= clock.millis()) {
calendar.add(Calendar.DAY_OF_YEAR, 1)
}
diff --git a/app/src/main/java/com/eatssu/android/presentation/map/MapFragmentView.kt b/app/src/main/java/com/eatssu/android/presentation/map/MapFragmentView.kt
index 08b16c838..fe70d22c1 100644
--- a/app/src/main/java/com/eatssu/android/presentation/map/MapFragmentView.kt
+++ b/app/src/main/java/com/eatssu/android/presentation/map/MapFragmentView.kt
@@ -3,13 +3,11 @@
package com.eatssu.android.presentation.map
import android.Manifest
-import android.R.id.message
import android.app.Activity
import android.content.Context
import android.content.ContextWrapper
import android.content.Intent
import android.content.pm.PackageManager
-import android.widget.Toast
import androidx.activity.compose.rememberLauncherForActivityResult
import androidx.activity.result.contract.ActivityResultContracts
import androidx.compose.foundation.Image
@@ -47,7 +45,6 @@ import androidx.compose.ui.res.painterResource
import androidx.compose.ui.res.stringResource
import androidx.compose.ui.tooling.preview.Preview
import androidx.compose.ui.unit.dp
-import androidx.compose.ui.unit.sp
import androidx.core.content.ContextCompat
import androidx.lifecycle.Lifecycle
import androidx.lifecycle.LifecycleEventObserver
@@ -64,6 +61,7 @@ import com.eatssu.android.presentation.map.component.FilterType
import com.eatssu.android.presentation.map.component.MapRestaurantBottomSheet
import com.eatssu.android.presentation.map.component.PartnershipFilterToggle
import com.eatssu.android.presentation.mypage.userinfo.UserInfoActivity
+import com.eatssu.android.presentation.util.ScreenshotTestSeam
import com.eatssu.android.presentation.util.TrackScreenViewEvent
import com.eatssu.android.presentation.util.showToast
import com.eatssu.common.EventLogger
@@ -102,6 +100,7 @@ fun MapRoute(
viewModel: MapViewModel = viewModel(),
mainViewModel: MainViewModel = viewModel()
) {
+ val deterministicMode = ScreenshotTestSeam.isEnabled
val uiState by viewModel.uiState.collectAsStateWithLifecycle()
// UiState에서 Success 상태인 실제 MapState 데이터만 추출
@@ -115,7 +114,6 @@ fun MapRoute(
val partnershipSheetState = rememberModalBottomSheetState(skipPartiallyExpanded = true)
val context = LocalContext.current
val activity = remember(context) { context.findActivityOrNull() }
- ?: throw IllegalStateException("FusedLocationSource는 Activity에서만 사용할 수 있습니다.")
val scope = rememberCoroutineScope()
val departmentId by viewModel.departmentId.collectAsStateWithLifecycle()
@@ -129,8 +127,9 @@ fun MapRoute(
}
// 위치 추적을 위한 locationSource 생성
- val locationSource = remember {
- FusedLocationSource(activity, PERMISSION_REQUEST_CODE)
+ val locationSource = remember(activity, deterministicMode) {
+ if (deterministicMode || activity == null) null
+ else FusedLocationSource(activity, PERMISSION_REQUEST_CODE)
}
// 위치 권한 요청 런처
@@ -158,28 +157,32 @@ fun MapRoute(
else -> "학과" to false
}
- LaunchedEffect(Unit) {
- viewModel.uiEvent.collectLatest { event ->
- when (event) {
- is UiEvent.ShowToast -> context.showToast(event)
+ if (!deterministicMode) {
+ LaunchedEffect(Unit) {
+ viewModel.uiEvent.collectLatest { event ->
+ when (event) {
+ is UiEvent.ShowToast -> context.showToast(event)
+ }
}
}
}
// 최초 실행 시 위치 권한 요청
- LaunchedEffect(Unit) {
- val fine =
- ContextCompat.checkSelfPermission(context, Manifest.permission.ACCESS_FINE_LOCATION)
- val coarse =
- ContextCompat.checkSelfPermission(context, Manifest.permission.ACCESS_COARSE_LOCATION)
-
- if (fine != PackageManager.PERMISSION_GRANTED || coarse != PackageManager.PERMISSION_GRANTED) {
- permissionLauncher.launch(
- arrayOf(
- Manifest.permission.ACCESS_FINE_LOCATION,
- Manifest.permission.ACCESS_COARSE_LOCATION
+ if (!deterministicMode) {
+ LaunchedEffect(Unit) {
+ val fine =
+ ContextCompat.checkSelfPermission(context, Manifest.permission.ACCESS_FINE_LOCATION)
+ val coarse =
+ ContextCompat.checkSelfPermission(context, Manifest.permission.ACCESS_COARSE_LOCATION)
+
+ if (fine != PackageManager.PERMISSION_GRANTED || coarse != PackageManager.PERMISSION_GRANTED) {
+ permissionLauncher.launch(
+ arrayOf(
+ Manifest.permission.ACCESS_FINE_LOCATION,
+ Manifest.permission.ACCESS_COARSE_LOCATION
+ )
)
- )
+ }
}
}
@@ -213,23 +216,24 @@ fun MapRoute(
val lifecycleOwner = LocalLifecycleOwner.current
// onResume 시마다 학과 정보 반영
- DisposableEffect(lifecycleOwner) {
- val observer = LifecycleEventObserver { _, event ->
- if (event == Lifecycle.Event.ON_RESUME) {
- Timber.d("MapFragmentComposeView: onResume -> 학과 정보 갱신")
- mainViewModel.refreshUserDepartment()
+ if (!deterministicMode) {
+ DisposableEffect(lifecycleOwner) {
+ val observer = LifecycleEventObserver { _, event ->
+ if (event == Lifecycle.Event.ON_RESUME) {
+ Timber.d("MapFragmentComposeView: onResume -> 학과 정보 갱신")
+ mainViewModel.refreshUserDepartment()
+ }
}
- }
- lifecycleOwner.lifecycle.addObserver(observer)
+ lifecycleOwner.lifecycle.addObserver(observer)
- onDispose {
- lifecycleOwner.lifecycle.removeObserver(observer)
+ onDispose {
+ lifecycleOwner.lifecycle.removeObserver(observer)
+ }
}
}
MapScreen(
mapState = mapState,
- viewModel = viewModel,
cameraPositionState = cameraPositionState,
locationSource = locationSource,
departmentSheetState = departmentSheetState,
@@ -261,6 +265,9 @@ fun MapRoute(
)
}
},
+ onSelectPartnershipByStoreName = { storeName, partnershipId ->
+ viewModel.selectPartnershipByStoreName(storeName, partnershipId)
+ },
onSelectedFilterChange = { filter ->
viewModel.setFilter(filter)
},
@@ -268,15 +275,16 @@ fun MapRoute(
collegeId = collegeId,
departmentName = departmentName,
selectedFilter = mapState.selectedFilter,
+ useDeterministicRenderer = deterministicMode,
+ deterministicStateLabel = "success",
)
}
@Composable
internal fun MapScreen(
mapState: MapState,
- viewModel: MapViewModel,
- cameraPositionState: CameraPositionState,
- locationSource: FusedLocationSource,
+ cameraPositionState: CameraPositionState?,
+ locationSource: FusedLocationSource?,
departmentSheetState: SheetState,
partnershipSheetState: SheetState,
showToast: (UiText, ToastType) -> Unit,
@@ -284,11 +292,14 @@ internal fun MapScreen(
onHideDepartmentSheet: () -> Unit = {},
onHidePartnershipSheet: () -> Unit = {},
animateCameraPositionTo: (LatLng, Double) -> Unit,
+ onSelectPartnershipByStoreName: (String, Int?) -> Unit,
onSelectedFilterChange: (FilterType) -> Unit,
departmentId: Long,
collegeId: Long,
departmentName: String?,
selectedFilter: FilterType,
+ useDeterministicRenderer: Boolean = false,
+ deterministicStateLabel: String = "success",
) {
Scaffold(
topBar = {
@@ -353,14 +364,35 @@ internal fun MapScreen(
.padding(innerPadding),
contentAlignment = Alignment.TopCenter,
) {
+ if (useDeterministicRenderer) {
+ MapDeterministicPlaceholder(
+ stateLabel = deterministicStateLabel,
+ modifier = Modifier.fillMaxSize()
+ )
+ PartnershipFilterToggle(
+ selected = selectedFilter,
+ onSelectedChange = onSelectedFilterChange,
+ modifier = Modifier.padding(top = 12.dp),
+ departmentName = departmentName.toString()
+ )
+ return@Box
+ }
+
+ val resolvedCameraPositionState = requireNotNull(cameraPositionState) {
+ "cameraPositionState must not be null when deterministic mode is disabled."
+ }
+ val resolvedLocationSource = requireNotNull(locationSource) {
+ "locationSource must not be null when deterministic mode is disabled."
+ }
+
NaverMap(
modifier = Modifier.fillMaxSize(),
- cameraPositionState = cameraPositionState,
+ cameraPositionState = resolvedCameraPositionState,
uiSettings = MapUiSettings(
isZoomControlEnabled = false,
isLocationButtonEnabled = true
),
- locationSource = locationSource,
+ locationSource = resolvedLocationSource,
contentPadding = PaddingValues(bottom = dimensionResource(R.dimen.bottom_nav_height)),
properties = MapProperties(
// 현재 다른 위치에 있는 경우에도 숭실대입구를 보여주어야 함
@@ -433,7 +465,7 @@ internal fun MapScreen(
}
},
onClickCluster = { info, _ ->
- animateCameraPositionTo(info.position, cameraPositionState.position.zoom)
+ animateCameraPositionTo(info.position, resolvedCameraPositionState.position.zoom)
true
},
onClickLeaf = { info, _ ->
@@ -447,7 +479,7 @@ internal fun MapScreen(
)
} else {
// 제휴 정보가 있을 때만 바텀시트 띄움
- viewModel.selectPartnershipByStoreName(partnership.storeName)
+ onSelectPartnershipByStoreName(partnership.storeName, null)
}
true
}
@@ -470,6 +502,23 @@ internal fun MapScreen(
}
}
+@Composable
+private fun MapDeterministicPlaceholder(
+ stateLabel: String,
+ modifier: Modifier = Modifier
+) {
+ Box(
+ modifier = modifier.background(Color(0xFFEFF5FB)),
+ contentAlignment = Alignment.Center
+ ) {
+ Text(
+ text = "MAP TEST MODE\n$stateLabel",
+ color = Color(0xFF3C4A59),
+ style = EatssuTheme.typography.body1
+ )
+ }
+}
+
// FusedLocationSource는 Activity에서만 활용 가능하기 때문에 확장 함수 생성
fun Context.findActivityOrNull(): Activity? = when (this) {
is Activity -> this
diff --git a/app/src/main/java/com/eatssu/android/presentation/mypage/terms/WebViewActivity.kt b/app/src/main/java/com/eatssu/android/presentation/mypage/terms/WebViewActivity.kt
index 0afe0b3a0..ca17de5a6 100644
--- a/app/src/main/java/com/eatssu/android/presentation/mypage/terms/WebViewActivity.kt
+++ b/app/src/main/java/com/eatssu/android/presentation/mypage/terms/WebViewActivity.kt
@@ -6,6 +6,7 @@ import android.webkit.WebView
import android.webkit.WebViewClient
import com.eatssu.android.databinding.ActivityWebviewBinding
import com.eatssu.android.presentation.base.BaseActivity
+import com.eatssu.android.presentation.util.ScreenshotTestSeam
import com.eatssu.common.EventLogger
import com.eatssu.common.enums.ScreenId
import timber.log.Timber
@@ -23,6 +24,26 @@ class WebViewActivity :
super.onCreate(savedInstanceState)
binding.webview.apply {
+ if (ScreenshotTestSeam.isEnabled) {
+ toolbarTitle.text = "WebView"
+ settings.javaScriptEnabled = false
+ loadDataWithBaseURL(
+ null,
+ """
+
+
+ EAT-SSU WebView Test Mode
+ Deterministic content for screenshot regression.
+
+
+ """.trimIndent(),
+ "text/html",
+ "utf-8",
+ null
+ )
+ return@apply
+ }
+
webViewClient = object : WebViewClient() {
// 렌더러 충돌 시 호출되는 콜백 (Android 8.0 이상)
@@ -76,6 +97,7 @@ class WebViewActivity :
override fun onResume() {
super.onResume()
+ if (ScreenshotTestSeam.isEnabled) return
val screenIdString = intent.getStringExtra("SCREEN_ID") ?: return
val screenId = ScreenId.entries.find { it.name == screenIdString } ?: return
diff --git a/app/src/main/java/com/eatssu/android/presentation/util/AnalyticsUtil.kt b/app/src/main/java/com/eatssu/android/presentation/util/AnalyticsUtil.kt
index e4ee00403..983d56a3d 100644
--- a/app/src/main/java/com/eatssu/android/presentation/util/AnalyticsUtil.kt
+++ b/app/src/main/java/com/eatssu/android/presentation/util/AnalyticsUtil.kt
@@ -9,5 +9,6 @@ import com.eatssu.common.enums.ScreenId
fun TrackScreenViewEvent(
screenId: ScreenId
) = LaunchedEffect(Unit) {
+ if (ScreenshotTestSeam.isEnabled) return@LaunchedEffect
EventLogger.screenView(screenId)
}
diff --git a/app/src/main/java/com/eatssu/android/presentation/util/CalendarUtil.kt b/app/src/main/java/com/eatssu/android/presentation/util/CalendarUtil.kt
index 756d0780e..2d8a1fc64 100644
--- a/app/src/main/java/com/eatssu/android/presentation/util/CalendarUtil.kt
+++ b/app/src/main/java/com/eatssu/android/presentation/util/CalendarUtil.kt
@@ -2,6 +2,7 @@ package com.eatssu.android.presentation.util
import java.text.SimpleDateFormat
import java.time.DayOfWeek
+import java.time.Clock
import java.time.LocalDate
import java.time.format.DateTimeFormatter
import java.util.Date
@@ -43,9 +44,9 @@ object CalendarUtil {
return formatter.format(date)
}
- fun getNextDayDate(): String {
- val nextDay = LocalDate.now().plusDays(1)
+ fun getNextDayDate(clock: Clock = Clock.systemDefaultZone()): String {
+ val nextDay = LocalDate.now(clock).plusDays(1)
val formatter = DateTimeFormatter.ofPattern("yyyyMMdd", Locale.getDefault())
return nextDay.format(formatter)
}
-}
\ No newline at end of file
+}
diff --git a/app/src/main/java/com/eatssu/android/presentation/util/ScreenshotTestSeam.kt b/app/src/main/java/com/eatssu/android/presentation/util/ScreenshotTestSeam.kt
new file mode 100644
index 000000000..a550d1bcb
--- /dev/null
+++ b/app/src/main/java/com/eatssu/android/presentation/util/ScreenshotTestSeam.kt
@@ -0,0 +1,25 @@
+package com.eatssu.android.presentation.util
+
+/**
+ * Screenshot regression tests toggle deterministic rendering through this seam.
+ * It is disabled by default and must be explicitly enabled in test code.
+ */
+object ScreenshotTestSeam {
+ private const val PROPERTY_KEY = "eatssu.screenshot.test"
+
+ @Volatile
+ private var forceEnabled: Boolean = false
+
+ val isEnabled: Boolean
+ get() = forceEnabled || System.getProperty(PROPERTY_KEY) == "true"
+
+ fun enableForTest() {
+ forceEnabled = true
+ System.setProperty(PROPERTY_KEY, "true")
+ }
+
+ fun disableForTest() {
+ forceEnabled = false
+ System.clearProperty(PROPERTY_KEY)
+ }
+}
diff --git a/app/src/main/java/com/eatssu/android/presentation/widget/WidgetCacheManager.kt b/app/src/main/java/com/eatssu/android/presentation/widget/WidgetCacheManager.kt
index 8691dca3c..50302769a 100644
--- a/app/src/main/java/com/eatssu/android/presentation/widget/WidgetCacheManager.kt
+++ b/app/src/main/java/com/eatssu/android/presentation/widget/WidgetCacheManager.kt
@@ -4,6 +4,7 @@ package com.eatssu.android.presentation.widget
import com.eatssu.android.domain.model.WidgetMealInfo
import com.eatssu.common.enums.Restaurant
import timber.log.Timber
+import java.time.Clock
import java.time.LocalDateTime
/**
@@ -27,8 +28,12 @@ object WidgetCacheManager {
/**
* 캐시된 데이터가 유효한지 확인
*/
- private fun isCacheValid(cachedData: CachedMealData, currentDate: String): Boolean {
- val now = LocalDateTime.now()
+ private fun isCacheValid(
+ cachedData: CachedMealData,
+ currentDate: String,
+ clock: Clock,
+ ): Boolean {
+ val now = LocalDateTime.now(clock)
val timeDiff = java.time.Duration.between(cachedData.timestamp, now)
return cachedData.date == currentDate &&
@@ -38,10 +43,14 @@ object WidgetCacheManager {
/**
* 캐시에서 식당별 메뉴 데이터 조회
*/
- fun getCachedMealData(restaurant: Restaurant, currentDate: String): WidgetMealInfo? {
+ fun getCachedMealData(
+ restaurant: Restaurant,
+ currentDate: String,
+ clock: Clock = Clock.systemDefaultZone(),
+ ): WidgetMealInfo? {
val cachedData = cacheMap[restaurant] ?: return null
- return if (isCacheValid(cachedData, currentDate)) {
+ return if (isCacheValid(cachedData, currentDate, clock)) {
Timber.d("Cache hit for ${restaurant.name} on $currentDate")
cachedData.mealInfo
} else {
@@ -54,10 +63,15 @@ object WidgetCacheManager {
/**
* 식당별 메뉴 데이터를 캐시에 저장
*/
- fun cacheMealData(restaurant: Restaurant, mealInfo: WidgetMealInfo, date: String) {
+ fun cacheMealData(
+ restaurant: Restaurant,
+ mealInfo: WidgetMealInfo,
+ date: String,
+ clock: Clock = Clock.systemDefaultZone(),
+ ) {
val cachedData = CachedMealData(
mealInfo = mealInfo,
- timestamp = LocalDateTime.now(),
+ timestamp = LocalDateTime.now(clock),
date = date
)
@@ -90,4 +104,4 @@ object WidgetCacheManager {
Timber.d("${restaurant.name}: ${data.date} at ${data.timestamp}")
}
}
-}
\ No newline at end of file
+}
diff --git a/app/src/main/java/com/eatssu/android/presentation/widget/util/WidgetDataDisplayManager.kt b/app/src/main/java/com/eatssu/android/presentation/widget/util/WidgetDataDisplayManager.kt
index 959f6a37c..48a83e55f 100644
--- a/app/src/main/java/com/eatssu/android/presentation/widget/util/WidgetDataDisplayManager.kt
+++ b/app/src/main/java/com/eatssu/android/presentation/widget/util/WidgetDataDisplayManager.kt
@@ -7,6 +7,7 @@ import com.eatssu.android.presentation.util.CalendarUtil
import com.eatssu.android.presentation.widget.WidgetCacheManager
import com.eatssu.common.enums.Restaurant
import timber.log.Timber
+import java.time.Clock
import java.time.LocalTime
sealed class MealTime {
@@ -37,12 +38,13 @@ object WidgetDataDisplayManager {
getMealsUseCase: GetTodayMealUseCase,
requestedMealTime: MealTime,
restaurant: Restaurant,
+ clock: Clock = Clock.systemDefaultZone(),
): WidgetMealInfo {
Timber.d("Widget - fetchMealInfo")
- val targetDate = CalendarUtil.convertMillisToDateString(System.currentTimeMillis())
+ val targetDate = CalendarUtil.convertMillisToDateString(clock.millis())
// 캐시에서 데이터 확인
- val cachedMealInfo = WidgetCacheManager.getCachedMealData(restaurant, targetDate)
+ val cachedMealInfo = WidgetCacheManager.getCachedMealData(restaurant, targetDate, clock)
if (cachedMealInfo != null) {
return cachedMealInfo
}
@@ -64,13 +66,13 @@ object WidgetDataDisplayManager {
)
// 캐시에 저장
- WidgetCacheManager.cacheMealData(restaurant, mealInfo, targetDate)
+ WidgetCacheManager.cacheMealData(restaurant, mealInfo, targetDate, clock)
return mealInfo
}
// 다음 날 데이터 확인
- val nextDay = CalendarUtil.getNextDayDate()
+ val nextDay = CalendarUtil.getNextDayDate(clock)
val getNextDayMealResponse = getMealsUseCase(nextDay, restaurant.name)
if (getNextDayMealResponse is MealState.Success) {
@@ -87,7 +89,7 @@ object WidgetDataDisplayManager {
)
// 캐시에 저장
- WidgetCacheManager.cacheMealData(restaurant, mealInfo, targetDate)
+ WidgetCacheManager.cacheMealData(restaurant, mealInfo, targetDate, clock)
return mealInfo
}
@@ -101,13 +103,15 @@ object WidgetDataDisplayManager {
)
// 캐시에 저장
- WidgetCacheManager.cacheMealData(restaurant, emptyMealInfo, targetDate)
+ WidgetCacheManager.cacheMealData(restaurant, emptyMealInfo, targetDate, clock)
return emptyMealInfo
}
- internal fun getCurrentMealTime(): MealTime {
- val currentTime = LocalTime.now()
+ internal fun getCurrentMealTime(
+ clock: Clock = Clock.systemDefaultZone(),
+ ): MealTime {
+ val currentTime = LocalTime.now(clock)
val morningEnd = LocalTime.of(9, 0)
val lunchEnd = LocalTime.of(15, 0)
@@ -117,4 +121,4 @@ object WidgetDataDisplayManager {
else -> MealTime.Dinner
}
}
-}
\ No newline at end of file
+}
diff --git a/app/src/test/java/com/eatssu/android/data/model/ApiResultBehaviorSpec.kt b/app/src/test/java/com/eatssu/android/data/model/ApiResultBehaviorSpec.kt
new file mode 100644
index 000000000..1c8ab3c71
--- /dev/null
+++ b/app/src/test/java/com/eatssu/android/data/model/ApiResultBehaviorSpec.kt
@@ -0,0 +1,56 @@
+package com.eatssu.android.data.model
+
+import com.eatssu.android.test.AppBehaviorSpec
+import io.kotest.matchers.shouldBe
+import java.io.IOException
+
+class ApiResultBehaviorSpec : AppBehaviorSpec({
+
+ given("ApiResult 확장 함수") {
+ `when`("isSuccess를 호출하면") {
+ then("Success(Unit)에서만 true를 반환한다") {
+ ApiResult.Success(Unit).isSuccess() shouldBe true
+ ApiResult.Failure(400, "bad").isSuccess() shouldBe false
+ }
+ }
+
+ `when`("orEmptyList를 호출하면") {
+ then("Success는 원본 리스트, 실패는 빈 리스트를 반환한다") {
+ ApiResult.Success(listOf(1, 2, 3)).orEmptyList() shouldBe listOf(1, 2, 3)
+ ApiResult.Failure(500, "err").orEmptyList>() shouldBe emptyList()
+ }
+ }
+
+ `when`("orElse를 호출하면") {
+ then("Success는 데이터, 실패는 기본값을 반환한다") {
+ ApiResult.Success("value").orElse("default") shouldBe "value"
+ ApiResult.Failure(401, "unauthorized").orElse("default") shouldBe "default"
+ }
+ }
+
+ `when`("orNull을 호출하면") {
+ then("Success는 데이터, 실패는 null을 반환한다") {
+ ApiResult.Success(42).orNull() shouldBe 42
+ ApiResult.Failure(500, null).orNull() shouldBe null
+ }
+ }
+
+ `when`("map을 호출하면") {
+ then("Success는 transform되고 실패 타입은 유지된다") {
+ ApiResult.Success(10).map { it * 2 } shouldBe ApiResult.Success(20)
+
+ val failureMapped = ApiResult.Failure(404, "not found").map { it.toString() }
+ (failureMapped as ApiResult.Failure).responseCode shouldBe 404
+ failureMapped.message shouldBe "not found"
+
+ val io = IOException("offline")
+ val networkMapped = ApiResult.NetworkError(io).map { it.toString() }
+ (networkMapped as ApiResult.NetworkError).exception shouldBe io
+
+ val unknown = IllegalStateException("boom")
+ val unknownMapped = ApiResult.UnknownError(unknown).map { it.toString() }
+ (unknownMapped as ApiResult.UnknownError).exception shouldBe unknown
+ }
+ }
+ }
+})
diff --git a/app/src/test/java/com/eatssu/android/data/remote/dto/response/MenuAndMealResponseMapperBehaviorSpec.kt b/app/src/test/java/com/eatssu/android/data/remote/dto/response/MenuAndMealResponseMapperBehaviorSpec.kt
new file mode 100644
index 000000000..eef27d392
--- /dev/null
+++ b/app/src/test/java/com/eatssu/android/data/remote/dto/response/MenuAndMealResponseMapperBehaviorSpec.kt
@@ -0,0 +1,124 @@
+package com.eatssu.android.data.remote.dto.response
+
+import com.eatssu.android.test.AppBehaviorSpec
+import io.kotest.matchers.collections.shouldHaveSize
+import io.kotest.matchers.shouldBe
+
+class MenuAndMealResponseMapperBehaviorSpec : AppBehaviorSpec({
+
+ given("GetFixedMenuResponse.mapFixedMenuResponseToMenu") {
+ `when`("카테고리/메뉴 응답이 주어지면") {
+ val response = GetFixedMenuResponse(
+ categoryMenuListCollection = arrayListOf(
+ CategoryMenuListCollection(
+ category = "A",
+ menus = arrayListOf(
+ MenuInformationList(menuId = 1L, name = "돈까스", price = 5500, rating = 4.3),
+ MenuInformationList(menuId = null, name = null, price = null, rating = null),
+ ),
+ ),
+ CategoryMenuListCollection(
+ category = "B",
+ menus = arrayListOf(
+ MenuInformationList(menuId = 2L, name = "비빔밥", price = 6000, rating = 4.0),
+ ),
+ ),
+ )
+ )
+
+ then("카테고리를 펼쳐 Menu 리스트로 매핑하고 null은 기본값으로 채운다") {
+ val result = response.mapFixedMenuResponseToMenu()
+ result shouldHaveSize 3
+ result[0].id shouldBe 1L
+ result[0].name shouldBe "돈까스"
+ result[1].id shouldBe 0L
+ result[1].name shouldBe ""
+ result[1].price shouldBe 0
+ result[1].rate shouldBe 0.0
+ result[2].id shouldBe 2L
+ result[2].name shouldBe "비빔밥"
+ }
+ }
+ }
+
+ given("List.mapTodayMenuResponseToMenu") {
+ `when`("식단 응답에 메뉴명이 일부 null로 섞여 있으면") {
+ val response = listOf(
+ GetMealResponse(
+ mealId = 5L,
+ price = 5000,
+ rating = 4.2,
+ briefMenus = listOf(
+ MenusInformationList(menuId = 1L, name = "제육"),
+ MenusInformationList(menuId = 2L, name = null),
+ MenusInformationList(menuId = 3L, name = "계란찜"),
+ ),
+ ),
+ GetMealResponse(
+ mealId = null,
+ price = null,
+ rating = null,
+ briefMenus = listOf(
+ MenusInformationList(menuId = 4L, name = null),
+ ),
+ ),
+ )
+
+ then("null 이름은 제외해 문자열로 결합하고 null 필드는 기본값으로 변환한다") {
+ val result = response.mapTodayMenuResponseToMenu()
+ result shouldHaveSize 2
+ result[0].id shouldBe 5L
+ result[0].name shouldBe "제육, 계란찜"
+ result[1].id shouldBe -1L
+ result[1].name shouldBe ""
+ result[1].price shouldBe 0
+ result[1].rate shouldBe 0.0
+ }
+ }
+ }
+
+ given("List.toDomain") {
+ `when`("식단 응답을 도메인 메뉴명 리스트로 변환하면") {
+ val response = listOf(
+ GetMealResponse(
+ briefMenus = listOf(
+ MenusInformationList(name = "짜장면"),
+ MenusInformationList(name = null),
+ ),
+ ),
+ GetMealResponse(
+ briefMenus = listOf(
+ MenusInformationList(name = "우동"),
+ ),
+ ),
+ )
+
+ then("meal 단위로 null이 제거된 문자열 리스트를 반환한다") {
+ response.toDomain() shouldBe listOf(
+ listOf("짜장면"),
+ listOf("우동"),
+ )
+ }
+ }
+ }
+
+ given("MenuOfMealResponse.toDomain") {
+ `when`("menuList를 변환하면") {
+ val response = MenuOfMealResponse(
+ menuList = arrayListOf(
+ MenuList(menuId = 1L, name = "덮밥"),
+ MenuList(menuId = null, name = null),
+ )
+ )
+
+ then("MenuMini 리스트로 매핑하고 null은 기본값으로 채운다") {
+ val result = response.toDomain()
+ result shouldHaveSize 2
+ result[0].id shouldBe 1L
+ result[0].name shouldBe "덮밥"
+ result[1].id shouldBe -1L
+ result[1].name shouldBe ""
+ }
+ }
+ }
+})
diff --git a/app/src/test/java/com/eatssu/android/data/remote/dto/response/PartnershipResponseMapperBehaviorSpec.kt b/app/src/test/java/com/eatssu/android/data/remote/dto/response/PartnershipResponseMapperBehaviorSpec.kt
new file mode 100644
index 000000000..c722b9cd8
--- /dev/null
+++ b/app/src/test/java/com/eatssu/android/data/remote/dto/response/PartnershipResponseMapperBehaviorSpec.kt
@@ -0,0 +1,210 @@
+package com.eatssu.android.data.remote.dto.response
+
+import com.eatssu.android.domain.model.RestaurantType
+import com.eatssu.android.test.AppBehaviorSpec
+import io.kotest.matchers.shouldBe
+
+class PartnershipResponseMapperBehaviorSpec : AppBehaviorSpec({
+
+ given("PartnershipResponse.toDomain") {
+ `when`("restaurantType이 CAFE/RESTAURANT/PUB이면") {
+ then("각 enum으로 매핑한다") {
+ PartnershipResponse(
+ storeName = "A",
+ longitude = 1.0,
+ latitude = 2.0,
+ restaurantType = "CAFE",
+ partnershipInfos = emptyList(),
+ ).toDomain().restaurantType shouldBe RestaurantType.CAFE
+
+ PartnershipResponse(
+ storeName = "B",
+ longitude = 1.0,
+ latitude = 2.0,
+ restaurantType = "RESTAURANT",
+ partnershipInfos = emptyList(),
+ ).toDomain().restaurantType shouldBe RestaurantType.RESTAURANT
+
+ PartnershipResponse(
+ storeName = "C",
+ longitude = 1.0,
+ latitude = 2.0,
+ restaurantType = "PUB",
+ partnershipInfos = emptyList(),
+ ).toDomain().restaurantType shouldBe RestaurantType.PUB
+ }
+ }
+
+ `when`("restaurantType이 null 또는 알 수 없는 값이면") {
+ then("RESTAURANT로 fallback한다") {
+ PartnershipResponse(
+ storeName = "D",
+ longitude = 1.0,
+ latitude = 2.0,
+ restaurantType = null,
+ partnershipInfos = emptyList(),
+ ).toDomain().restaurantType shouldBe RestaurantType.RESTAURANT
+
+ PartnershipResponse(
+ storeName = "E",
+ longitude = 1.0,
+ latitude = 2.0,
+ restaurantType = "UNKNOWN",
+ partnershipInfos = emptyList(),
+ ).toDomain().restaurantType shouldBe RestaurantType.RESTAURANT
+ }
+ }
+
+ `when`("필드가 null인 응답을 매핑하면") {
+ val result = PartnershipResponse(
+ storeName = null,
+ longitude = null,
+ latitude = null,
+ restaurantType = null,
+ partnershipInfos = listOf(
+ PartnershipResponse.PartnershipInfo(
+ id = null,
+ partnershipType = null,
+ collegeName = null,
+ departmentName = null,
+ likeCount = null,
+ isLiked = null,
+ description = null,
+ startDate = null,
+ endDate = null,
+ )
+ ),
+ ).toDomain()
+
+ then("기본값으로 채운다") {
+ result.storeName shouldBe ""
+ result.longitude shouldBe 126.95661313346206
+ result.latitude shouldBe 37.49517278813046
+ result.partnershipInfos.first().id shouldBe -1
+ result.partnershipInfos.first().partnershipType shouldBe ""
+ result.partnershipInfos.first().likeCount shouldBe 0
+ result.partnershipInfos.first().isLiked shouldBe false
+ }
+ }
+ }
+
+ given("PartnershipRestaurantResponse.toDomain") {
+ `when`("restaurantType이 CAFE/RESTAURANT/PUB이면") {
+ then("각 enum으로 매핑한다") {
+ PartnershipRestaurantResponse(
+ id = 1,
+ partnershipType = "DISCOUNT",
+ storeName = "A",
+ description = "desc",
+ startDate = "2025-01-01",
+ endDate = "2025-12-31",
+ restaurantType = "CAFE",
+ longitude = 1.0,
+ latitude = 2.0,
+ collegeName = "IT",
+ departmentName = "CS",
+ partnershipLikeCount = 1,
+ likedByUser = true,
+ ).toDomain().restaurantType shouldBe RestaurantType.CAFE
+
+ PartnershipRestaurantResponse(
+ id = 1,
+ partnershipType = "DISCOUNT",
+ storeName = "A",
+ description = "desc",
+ startDate = "2025-01-01",
+ endDate = "2025-12-31",
+ restaurantType = "RESTAURANT",
+ longitude = 1.0,
+ latitude = 2.0,
+ collegeName = "IT",
+ departmentName = "CS",
+ partnershipLikeCount = 1,
+ likedByUser = true,
+ ).toDomain().restaurantType shouldBe RestaurantType.RESTAURANT
+
+ PartnershipRestaurantResponse(
+ id = 1,
+ partnershipType = "DISCOUNT",
+ storeName = "A",
+ description = "desc",
+ startDate = "2025-01-01",
+ endDate = "2025-12-31",
+ restaurantType = "PUB",
+ longitude = 1.0,
+ latitude = 2.0,
+ collegeName = "IT",
+ departmentName = "CS",
+ partnershipLikeCount = 1,
+ likedByUser = true,
+ ).toDomain().restaurantType shouldBe RestaurantType.PUB
+ }
+ }
+
+ `when`("restaurantType이 null 또는 알 수 없는 값이면") {
+ then("RESTAURANT로 fallback한다") {
+ PartnershipRestaurantResponse(
+ id = 1,
+ partnershipType = "DISCOUNT",
+ storeName = "A",
+ description = "desc",
+ startDate = "2025-01-01",
+ endDate = "2025-12-31",
+ restaurantType = null,
+ longitude = 1.0,
+ latitude = 2.0,
+ collegeName = "IT",
+ departmentName = "CS",
+ partnershipLikeCount = 1,
+ likedByUser = true,
+ ).toDomain().restaurantType shouldBe RestaurantType.RESTAURANT
+
+ PartnershipRestaurantResponse(
+ id = 1,
+ partnershipType = "DISCOUNT",
+ storeName = "A",
+ description = "desc",
+ startDate = "2025-01-01",
+ endDate = "2025-12-31",
+ restaurantType = "UNKNOWN",
+ longitude = 1.0,
+ latitude = 2.0,
+ collegeName = "IT",
+ departmentName = "CS",
+ partnershipLikeCount = 1,
+ likedByUser = true,
+ ).toDomain().restaurantType shouldBe RestaurantType.RESTAURANT
+ }
+ }
+
+ `when`("필드가 null인 응답을 매핑하면") {
+ val result = PartnershipRestaurantResponse(
+ id = null,
+ partnershipType = null,
+ storeName = null,
+ description = null,
+ startDate = null,
+ endDate = null,
+ restaurantType = null,
+ longitude = null,
+ latitude = null,
+ collegeName = null,
+ departmentName = null,
+ partnershipLikeCount = null,
+ likedByUser = null,
+ ).toDomain()
+
+ then("기본값으로 채운다") {
+ result.id shouldBe -1
+ result.partnershipType shouldBe ""
+ result.storeName shouldBe ""
+ result.longitude shouldBe 126.95661313346206
+ result.latitude shouldBe 37.49517278813046
+ result.collegeName shouldBe ""
+ result.departmentName shouldBe ""
+ result.partnershipLikeCount shouldBe 0
+ result.likedByUser shouldBe false
+ }
+ }
+ }
+})
diff --git a/app/src/test/java/com/eatssu/android/data/remote/dto/response/ReviewResponseMapperBehaviorSpec.kt b/app/src/test/java/com/eatssu/android/data/remote/dto/response/ReviewResponseMapperBehaviorSpec.kt
new file mode 100644
index 000000000..c7a425a70
--- /dev/null
+++ b/app/src/test/java/com/eatssu/android/data/remote/dto/response/ReviewResponseMapperBehaviorSpec.kt
@@ -0,0 +1,201 @@
+package com.eatssu.android.data.remote.dto.response
+
+import com.eatssu.android.test.AppBehaviorSpec
+import io.kotest.matchers.collections.shouldHaveSize
+import io.kotest.matchers.shouldBe
+
+class ReviewResponseMapperBehaviorSpec : AppBehaviorSpec({
+
+ given("MenuReviewInfoResponse.toDomain") {
+ `when`("rating/count가 주어지면") {
+ then("소수 첫째 자리 반올림 및 null 카운트 기본값을 적용한다") {
+ val result = MenuReviewInfoResponse(
+ totalReviewCount = 10,
+ rating = 4.44,
+ reviewRatingCount = MenuReviewInfoResponse.ReviewRatingCount(
+ oneStarCount = 1,
+ twoStarCount = 2,
+ threeStarCount = 3,
+ fourStarCount = 4,
+ fiveStarCount = 5,
+ ),
+ ).toDomain()
+
+ result.reviewCnt shouldBe 10
+ result.rating shouldBe 4.4
+ result.oneStarCount shouldBe 1
+ result.fiveStarCount shouldBe 5
+ }
+ }
+
+ `when`("reviewRatingCount가 null이면") {
+ then("별점 카운트는 모두 0으로 채운다") {
+ val result = MenuReviewInfoResponse(
+ totalReviewCount = null,
+ rating = null,
+ reviewRatingCount = null,
+ ).toDomain()
+
+ result.reviewCnt shouldBe 0
+ result.rating shouldBe 0.0
+ result.oneStarCount shouldBe 0
+ result.fiveStarCount shouldBe 0
+ }
+ }
+ }
+
+ given("MealReviewInfoResponse.toDomain") {
+ `when`("rating/count가 주어지면") {
+ then("소수 첫째 자리 반올림 및 기본값 매핑을 적용한다") {
+ val result = MealReviewInfoResponse(
+ totalReviewCount = 7,
+ rating = 3.66,
+ reviewRatingCount = MealReviewInfoResponse.ReviewRatingCount(
+ oneStarCount = 0,
+ twoStarCount = 1,
+ threeStarCount = 2,
+ fourStarCount = 3,
+ fiveStarCount = 1,
+ ),
+ ).toDomain()
+
+ result.reviewCnt shouldBe 7
+ result.rating shouldBe 3.7
+ result.twoStarCount shouldBe 1
+ result.fourStarCount shouldBe 3
+ }
+ }
+ }
+
+ given("MenuReviewListResponse?.toDomain") {
+ `when`("응답 자체가 null이면") {
+ then("빈 리스트를 반환한다") {
+ (null as MenuReviewListResponse?).toDomain() shouldBe emptyList()
+ }
+ }
+
+ `when`("dataList를 도메인으로 변환하면") {
+ val response = MenuReviewListResponse(
+ dataList = listOf(
+ MenuReviewListResponse.DataList(
+ reviewId = 1L,
+ menu = MenuReviewListResponse.DataList.Menu(
+ id = 10L,
+ name = "돈까스",
+ isLike = true,
+ ),
+ isWriter = true,
+ writerNickname = "writer",
+ rating = 5,
+ writtenAt = "2025-01-01",
+ content = "great",
+ imageUrls = listOf("https://img1", "https://img2"),
+ ),
+ MenuReviewListResponse.DataList(
+ reviewId = null,
+ menu = null,
+ isWriter = null,
+ writerNickname = null,
+ rating = null,
+ writtenAt = null,
+ content = null,
+ imageUrls = emptyList(),
+ ),
+ )
+ )
+
+ then("기본값과 첫 번째 이미지 URL 규칙을 적용한다") {
+ val result = response.toDomain()
+ result shouldHaveSize 2
+
+ result[0].reviewId shouldBe 1L
+ result[0].menuLikeInfoList.first().menuId shouldBe 10L
+ result[0].menuLikeInfoList.first().isLike shouldBe true
+ result[0].imgUrl shouldBe "https://img1"
+
+ result[1].reviewId shouldBe -1L
+ result[1].menuLikeInfoList.first().menuId shouldBe -1L
+ result[1].menuLikeInfoList.first().name shouldBe ""
+ result[1].isWriter shouldBe false
+ result[1].imgUrl shouldBe null
+ }
+ }
+ }
+
+ given("MealReviewListResponse?.toDomain") {
+ `when`("응답 자체가 null이면") {
+ then("빈 리스트를 반환한다") {
+ (null as MealReviewListResponse?).toDomain() shouldBe emptyList()
+ }
+ }
+
+ `when`("dataList를 도메인으로 변환하면") {
+ val response = MealReviewListResponse(
+ dataList = listOf(
+ MealReviewListResponse.DataList(
+ reviewId = 3L,
+ menuList = listOf(
+ MealReviewListResponse.DataList.MenuList(id = 1L, name = "제육", isLike = true),
+ MealReviewListResponse.DataList.MenuList(id = null, name = null, isLike = null),
+ ),
+ isWriter = false,
+ writerNickname = "other",
+ rating = 4,
+ writtenAt = "2025-01-02",
+ content = "ok",
+ imageUrls = listOf("https://meal"),
+ )
+ )
+ )
+
+ then("menuList를 포함해 도메인 Review로 매핑한다") {
+ val result = response.toDomain()
+ result shouldHaveSize 1
+ result.first().reviewId shouldBe 3L
+ result.first().menuLikeInfoList shouldHaveSize 2
+ result.first().menuLikeInfoList[0].name shouldBe "제육"
+ result.first().menuLikeInfoList[1].menuId shouldBe -1L
+ result.first().menuLikeInfoList[1].isLike shouldBe false
+ result.first().imgUrl shouldBe "https://meal"
+ }
+ }
+ }
+
+ given("MyReviewListResponse?.toDomain") {
+ `when`("응답 자체가 null이면") {
+ then("빈 리스트를 반환한다") {
+ (null as MyReviewListResponse?).toDomain() shouldBe emptyList()
+ }
+ }
+
+ `when`("dataList를 도메인으로 변환하면") {
+ val response = MyReviewListResponse(
+ dataList = arrayListOf(
+ MyReviewListResponse.DataList(
+ reviewId = 100L,
+ rating = 5,
+ writtenAt = "2025-01-03",
+ content = "best",
+ imageUrls = arrayListOf("https://my"),
+ menuList = arrayListOf(
+ MyReviewListResponse.DataList.MenuList(id = 9L, name = "라면", isLike = true),
+ MyReviewListResponse.DataList.MenuList(id = null, name = null, isLike = null),
+ ),
+ )
+ )
+ )
+
+ then("isWriter=true로 고정하고 기본값 매핑을 적용한다") {
+ val result = response.toDomain()
+ result shouldHaveSize 1
+ result.first().isWriter shouldBe true
+ result.first().reviewId shouldBe 100L
+ result.first().menuLikeInfoList shouldHaveSize 2
+ result.first().menuLikeInfoList[0].name shouldBe "라면"
+ result.first().menuLikeInfoList[1].menuId shouldBe -1L
+ result.first().writerNickname shouldBe ""
+ result.first().imgUrl shouldBe "https://my"
+ }
+ }
+ }
+})
diff --git a/app/src/test/java/com/eatssu/android/data/remote/dto/response/UserAndTokenResponseMapperBehaviorSpec.kt b/app/src/test/java/com/eatssu/android/data/remote/dto/response/UserAndTokenResponseMapperBehaviorSpec.kt
new file mode 100644
index 000000000..88597392d
--- /dev/null
+++ b/app/src/test/java/com/eatssu/android/data/remote/dto/response/UserAndTokenResponseMapperBehaviorSpec.kt
@@ -0,0 +1,91 @@
+package com.eatssu.android.data.remote.dto.response
+
+import com.eatssu.android.test.AppBehaviorSpec
+import io.kotest.matchers.nulls.shouldBeNull
+import io.kotest.matchers.shouldBe
+
+class UserAndTokenResponseMapperBehaviorSpec : AppBehaviorSpec({
+
+ given("CollegeResponse.toDomain") {
+ `when`("collegeId가 null이면") {
+ then("null을 반환한다") {
+ CollegeResponse(collegeId = null, collegeName = "IT").toDomain().shouldBeNull()
+ }
+ }
+
+ `when`("collegeName이 null이면") {
+ then("null을 반환한다") {
+ CollegeResponse(collegeId = 1, collegeName = null).toDomain().shouldBeNull()
+ }
+ }
+
+ `when`("id/name이 모두 존재하면") {
+ then("College로 매핑한다") {
+ val result = CollegeResponse(collegeId = 1, collegeName = "IT").toDomain()
+ result?.collegeId shouldBe 1
+ result?.collegeName shouldBe "IT"
+ }
+ }
+ }
+
+ given("DepartmentResponse.toDomain") {
+ `when`("departmentId가 null이면") {
+ then("null을 반환한다") {
+ DepartmentResponse(departmentId = null, departmentName = "컴퓨터학부").toDomain().shouldBeNull()
+ }
+ }
+
+ `when`("departmentName이 null이면") {
+ then("null을 반환한다") {
+ DepartmentResponse(departmentId = 10, departmentName = null).toDomain().shouldBeNull()
+ }
+ }
+
+ `when`("id/name이 모두 존재하면") {
+ then("Department로 매핑한다") {
+ val result = DepartmentResponse(departmentId = 10, departmentName = "컴퓨터학부").toDomain()
+ result?.departmentId shouldBe 10
+ result?.departmentName shouldBe "컴퓨터학부"
+ }
+ }
+ }
+
+ given("UserCollegeDepartmentResponse.toDomain") {
+ `when`("필수 필드 중 하나라도 null이면") {
+ then("null을 반환한다") {
+ UserCollegeDepartmentResponse(
+ departmentId = 1,
+ departmentName = "컴퓨터학부",
+ collegeId = null,
+ collegeName = "IT",
+ ).toDomain().shouldBeNull()
+ }
+ }
+
+ `when`("필수 필드가 모두 존재하면") {
+ then("College/Department Pair를 반환한다") {
+ val result = UserCollegeDepartmentResponse(
+ departmentId = 3,
+ departmentName = "산업공학과",
+ collegeId = 2,
+ collegeName = "공과대학",
+ ).toDomain()
+
+ result?.first?.collegeId shouldBe 2
+ result?.first?.collegeName shouldBe "공과대학"
+ result?.second?.departmentId shouldBe 3
+ result?.second?.departmentName shouldBe "산업공학과"
+ }
+ }
+ }
+
+ given("TokenResponse.toDomain") {
+ `when`("access/refresh 토큰이 주어지면") {
+ then("도메인 Token으로 매핑한다") {
+ val result = TokenResponse(accessToken = "a", refreshToken = "r").toDomain()
+ result.accessToken shouldBe "a"
+ result.refreshToken shouldBe "r"
+ }
+ }
+ }
+})
diff --git a/app/src/test/java/com/eatssu/android/data/remote/paging/BaseReviewPagingSourceBehaviorSpec.kt b/app/src/test/java/com/eatssu/android/data/remote/paging/BaseReviewPagingSourceBehaviorSpec.kt
new file mode 100644
index 000000000..726aad12f
--- /dev/null
+++ b/app/src/test/java/com/eatssu/android/data/remote/paging/BaseReviewPagingSourceBehaviorSpec.kt
@@ -0,0 +1,134 @@
+package com.eatssu.android.data.remote.paging
+
+import androidx.paging.PagingSource
+import androidx.paging.PagingState
+import com.eatssu.android.domain.model.Review
+import com.eatssu.android.test.AppBehaviorSpec
+import io.kotest.matchers.shouldBe
+import kotlinx.coroutines.ExperimentalCoroutinesApi
+import kotlinx.coroutines.test.runTest
+
+private class TestBaseReviewPagingSource(
+ private val execute: suspend (page: Int, size: Int) -> TestResponse,
+) : BaseReviewPagingSource() {
+ override suspend fun executeRequest(page: Int, size: Int): TestResponse = execute(page, size)
+ override fun TestResponse.toReviewList(): List = reviews
+ override fun TestResponse.hasMorePages(): Boolean = hasNext
+}
+
+private data class TestResponse(
+ val reviews: List,
+ val hasNext: Boolean,
+)
+
+@OptIn(ExperimentalCoroutinesApi::class)
+class BaseReviewPagingSourceBehaviorSpec : AppBehaviorSpec({
+
+ given("BaseReviewPagingSource") {
+ `when`("첫 페이지 조회가 성공하고 다음 페이지가 있으면") {
+ val source = TestBaseReviewPagingSource { _, _ ->
+ TestResponse(
+ reviews = listOf(
+ Review(
+ reviewId = 1L,
+ isWriter = true,
+ menuLikeInfoList = emptyList(),
+ writerNickname = "writer",
+ rating = 5,
+ writeDate = "2025-01-01",
+ content = "good",
+ imgUrl = null,
+ )
+ ),
+ hasNext = true,
+ )
+ }
+
+ then("prevKey는 null, nextKey는 1인 Page를 반환한다") {
+ runTest {
+ val result = source.load(
+ PagingSource.LoadParams.Refresh(
+ key = null,
+ loadSize = 20,
+ placeholdersEnabled = false,
+ )
+ ) as PagingSource.LoadResult.Page
+
+ result.prevKey shouldBe null
+ result.nextKey shouldBe 1
+ result.data.size shouldBe 1
+ }
+ }
+ }
+
+ `when`("중간 페이지 조회가 성공하고 다음 페이지가 없으면") {
+ val source = TestBaseReviewPagingSource { _, _ ->
+ TestResponse(reviews = emptyList(), hasNext = false)
+ }
+
+ then("prevKey는 page-1, nextKey는 null이다") {
+ runTest {
+ val result = source.load(
+ PagingSource.LoadParams.Append(
+ key = 2,
+ loadSize = 20,
+ placeholdersEnabled = false,
+ )
+ ) as PagingSource.LoadResult.Page
+
+ result.prevKey shouldBe 1
+ result.nextKey shouldBe null
+ }
+ }
+ }
+
+ `when`("요청 중 예외가 발생하면") {
+ val source = TestBaseReviewPagingSource { _, _ ->
+ throw IllegalStateException("boom")
+ }
+
+ then("LoadResult.Error를 반환한다") {
+ runTest {
+ val result = source.load(
+ PagingSource.LoadParams.Refresh(
+ key = null,
+ loadSize = 20,
+ placeholdersEnabled = false,
+ )
+ )
+ (result is PagingSource.LoadResult.Error) shouldBe true
+ }
+ }
+ }
+
+ `when`("getRefreshKey를 호출하면") {
+ val source = TestBaseReviewPagingSource { _, _ -> TestResponse(emptyList(), hasNext = true) }
+ val page = PagingSource.LoadResult.Page(
+ data = listOf(
+ Review(
+ reviewId = 1L,
+ isWriter = false,
+ menuLikeInfoList = emptyList(),
+ writerNickname = "writer",
+ rating = 3,
+ writeDate = "2025-01-01",
+ content = "ok",
+ imgUrl = null,
+ )
+ ),
+ prevKey = 3,
+ nextKey = 5,
+ )
+ val state = PagingState(
+ pages = listOf(page),
+ anchorPosition = 0,
+ config = androidx.paging.PagingConfig(pageSize = 20),
+ leadingPlaceholderCount = 0,
+ )
+
+ then("anchor 기준으로 적절한 refresh key를 계산한다") {
+ source.getRefreshKey(state) shouldBe 4
+ }
+ }
+ }
+})
diff --git a/app/src/test/java/com/eatssu/android/data/remote/paging/MealReviewPagingSourceBehaviorSpec.kt b/app/src/test/java/com/eatssu/android/data/remote/paging/MealReviewPagingSourceBehaviorSpec.kt
new file mode 100644
index 000000000..666d25668
--- /dev/null
+++ b/app/src/test/java/com/eatssu/android/data/remote/paging/MealReviewPagingSourceBehaviorSpec.kt
@@ -0,0 +1,101 @@
+package com.eatssu.android.data.remote.paging
+
+import androidx.paging.PagingSource
+import com.eatssu.android.data.model.ApiResult
+import com.eatssu.android.data.remote.dto.response.MealReviewListResponse
+import com.eatssu.android.data.remote.service.ReviewService
+import com.eatssu.android.test.AppBehaviorSpec
+import io.kotest.matchers.shouldBe
+import io.mockk.coEvery
+import io.mockk.mockk
+import kotlinx.coroutines.ExperimentalCoroutinesApi
+import kotlinx.coroutines.test.runTest
+import java.io.IOException
+
+@OptIn(ExperimentalCoroutinesApi::class)
+class MealReviewPagingSourceBehaviorSpec : AppBehaviorSpec({
+
+ given("MealReviewPagingSource") {
+ val reviewService = mockk()
+
+ `when`("API가 성공하면") {
+ val response = MealReviewListResponse(
+ numberOfElements = 1,
+ hasNext = false,
+ dataList = listOf(
+ MealReviewListResponse.DataList(
+ reviewId = 20L,
+ menuList = listOf(
+ MealReviewListResponse.DataList.MenuList(
+ id = 200L,
+ name = "비빔밥",
+ isLike = false,
+ )
+ ),
+ isWriter = false,
+ writerNickname = "guest",
+ rating = 4,
+ writtenAt = "2025-01-02",
+ content = "nice",
+ imageUrls = emptyList(),
+ )
+ ),
+ )
+ coEvery { reviewService.getMealReviewList(2L, 0, 20, any()) } returns ApiResult.Success(response)
+ val source = MealReviewPagingSource(reviewService, mealId = 2L)
+
+ then("도메인 리뷰를 담은 Page를 반환한다") {
+ runTest {
+ val result = source.load(
+ PagingSource.LoadParams.Refresh(
+ key = null,
+ loadSize = 20,
+ placeholdersEnabled = false,
+ )
+ ) as PagingSource.LoadResult.Page
+
+ result.data.first().reviewId shouldBe 20L
+ result.nextKey shouldBe null
+ }
+ }
+ }
+
+ `when`("API Failure를 받으면") {
+ coEvery { reviewService.getMealReviewList(2L, 0, 20, any()) } returns ApiResult.Failure(500, "oops")
+ val source = MealReviewPagingSource(reviewService, mealId = 2L)
+
+ then("LoadResult.Error를 반환한다") {
+ runTest {
+ val result = source.load(
+ PagingSource.LoadParams.Refresh(
+ key = null,
+ loadSize = 20,
+ placeholdersEnabled = false,
+ )
+ )
+ (result is PagingSource.LoadResult.Error) shouldBe true
+ }
+ }
+ }
+
+ `when`("API UnknownError를 받으면") {
+ coEvery {
+ reviewService.getMealReviewList(2L, 0, 20, any())
+ } returns ApiResult.UnknownError(IOException("boom"))
+ val source = MealReviewPagingSource(reviewService, mealId = 2L)
+
+ then("LoadResult.Error를 반환한다") {
+ runTest {
+ val result = source.load(
+ PagingSource.LoadParams.Refresh(
+ key = null,
+ loadSize = 20,
+ placeholdersEnabled = false,
+ )
+ )
+ (result is PagingSource.LoadResult.Error) shouldBe true
+ }
+ }
+ }
+ }
+})
diff --git a/app/src/test/java/com/eatssu/android/data/remote/paging/MenuReviewPagingSourceBehaviorSpec.kt b/app/src/test/java/com/eatssu/android/data/remote/paging/MenuReviewPagingSourceBehaviorSpec.kt
new file mode 100644
index 000000000..fba5c3cd6
--- /dev/null
+++ b/app/src/test/java/com/eatssu/android/data/remote/paging/MenuReviewPagingSourceBehaviorSpec.kt
@@ -0,0 +1,99 @@
+package com.eatssu.android.data.remote.paging
+
+import androidx.paging.PagingSource
+import com.eatssu.android.data.model.ApiResult
+import com.eatssu.android.data.remote.dto.response.MenuReviewListResponse
+import com.eatssu.android.data.remote.service.ReviewService
+import com.eatssu.android.test.AppBehaviorSpec
+import io.kotest.matchers.shouldBe
+import io.mockk.coEvery
+import io.mockk.mockk
+import kotlinx.coroutines.ExperimentalCoroutinesApi
+import kotlinx.coroutines.test.runTest
+import java.io.IOException
+
+@OptIn(ExperimentalCoroutinesApi::class)
+class MenuReviewPagingSourceBehaviorSpec : AppBehaviorSpec({
+
+ given("MenuReviewPagingSource") {
+ val reviewService = mockk()
+
+ `when`("API가 성공하면") {
+ val response = MenuReviewListResponse(
+ numberOfElements = 1,
+ hasNext = true,
+ dataList = listOf(
+ MenuReviewListResponse.DataList(
+ reviewId = 10L,
+ menu = MenuReviewListResponse.DataList.Menu(
+ id = 100L,
+ name = "돈까스",
+ isLike = true,
+ ),
+ isWriter = true,
+ writerNickname = "writer",
+ rating = 5,
+ writtenAt = "2025-01-01",
+ content = "good",
+ imageUrls = emptyList(),
+ )
+ ),
+ )
+ coEvery { reviewService.getMenuReviewList(1L, 0, 20, any()) } returns ApiResult.Success(response)
+ val source = MenuReviewPagingSource(reviewService, menuId = 1L)
+
+ then("도메인 리뷰를 담은 Page를 반환한다") {
+ runTest {
+ val result = source.load(
+ PagingSource.LoadParams.Refresh(
+ key = null,
+ loadSize = 20,
+ placeholdersEnabled = false,
+ )
+ ) as PagingSource.LoadResult.Page
+
+ result.data.first().reviewId shouldBe 10L
+ result.nextKey shouldBe 1
+ }
+ }
+ }
+
+ `when`("API Failure를 받으면") {
+ coEvery { reviewService.getMenuReviewList(1L, 0, 20, any()) } returns ApiResult.Failure(400, "bad")
+ val source = MenuReviewPagingSource(reviewService, menuId = 1L)
+
+ then("LoadResult.Error를 반환한다") {
+ runTest {
+ val result = source.load(
+ PagingSource.LoadParams.Refresh(
+ key = null,
+ loadSize = 20,
+ placeholdersEnabled = false,
+ )
+ )
+ (result is PagingSource.LoadResult.Error) shouldBe true
+ }
+ }
+ }
+
+ `when`("API NetworkError를 받으면") {
+ coEvery {
+ reviewService.getMenuReviewList(1L, 0, 20, any())
+ } returns ApiResult.NetworkError(IOException("offline"))
+ val source = MenuReviewPagingSource(reviewService, menuId = 1L)
+
+ then("LoadResult.Error를 반환한다") {
+ runTest {
+ val result = source.load(
+ PagingSource.LoadParams.Refresh(
+ key = null,
+ loadSize = 20,
+ placeholdersEnabled = false,
+ )
+ )
+ (result is PagingSource.LoadResult.Error) shouldBe true
+ }
+ }
+ }
+ }
+})
diff --git a/app/src/test/java/com/eatssu/android/data/remote/repository/FirebaseRemoteConfigRepositoryImplBehaviorSpec.kt b/app/src/test/java/com/eatssu/android/data/remote/repository/FirebaseRemoteConfigRepositoryImplBehaviorSpec.kt
new file mode 100644
index 000000000..ff1bdd825
--- /dev/null
+++ b/app/src/test/java/com/eatssu/android/data/remote/repository/FirebaseRemoteConfigRepositoryImplBehaviorSpec.kt
@@ -0,0 +1,96 @@
+package com.eatssu.android.data.remote.repository
+
+import com.eatssu.android.R
+import com.eatssu.common.enums.Restaurant
+import com.google.android.gms.tasks.Tasks
+import com.google.firebase.remoteconfig.FirebaseRemoteConfig
+import com.eatssu.android.test.AppBehaviorSpec
+import io.kotest.matchers.nulls.shouldBeNull
+import io.kotest.matchers.shouldBe
+import io.mockk.every
+import io.mockk.mockk
+import io.mockk.mockkStatic
+import io.mockk.verify
+import kotlinx.coroutines.ExperimentalCoroutinesApi
+import kotlinx.coroutines.test.runTest
+
+@OptIn(ExperimentalCoroutinesApi::class)
+class FirebaseRemoteConfigRepositoryImplBehaviorSpec : AppBehaviorSpec({
+
+ given("FirebaseRemoteConfigRepositoryImpl") {
+ val remoteConfig = mockk(relaxed = true)
+ mockkStatic(FirebaseRemoteConfig::class)
+ every { FirebaseRemoteConfig.getInstance() } returns remoteConfig
+ every { remoteConfig.setConfigSettingsAsync(any()) } returns Tasks.forResult(null)
+ every { remoteConfig.setDefaultsAsync(R.xml.firebase_remote_config) } returns Tasks.forResult(null)
+
+ val repository = FirebaseRemoteConfigRepositoryImpl()
+
+ `when`("minimum version fetch가 성공하면") {
+ every { remoteConfig.fetchAndActivate() } returns Tasks.forResult(true)
+ every { remoteConfig.getLong("android_version_code") } returns 321L
+
+ then("최소 버전 코드를 반환한다") {
+ runTest {
+ repository.getMinimumVersionCode() shouldBe 321L
+ verify(exactly = 1) { remoteConfig.fetchAndActivate() }
+ verify(exactly = 1) { remoteConfig.getLong("android_version_code") }
+ }
+ }
+ }
+
+ `when`("minimum version fetch가 실패해도") {
+ every { remoteConfig.fetchAndActivate() } returns Tasks.forException(IllegalStateException("fetch fail"))
+ every { remoteConfig.getLong("android_version_code") } returns 100L
+
+ then("예외를 삼키고 캐시 값 반환을 시도한다") {
+ runTest {
+ repository.getMinimumVersionCode() shouldBe 100L
+ }
+ }
+ }
+
+ `when`("식당 정보 JSON이 유효하고 대상 enum이 존재하면") {
+ every { remoteConfig.fetchAndActivate() } returns Tasks.forResult(true)
+ every { remoteConfig.getString("cafeteria_information") } returns """
+ [
+ {"enum":"HAKSIK","name":"학식당","location":"B1","image":"a.png","time":"11:00-14:00","etc":"-"},
+ {"enum":"DODAM","name":"도담","location":"1F","image":"b.png","time":"11:00-14:00","etc":"-"}
+ ]
+ """.trimIndent()
+
+ then("요청한 식당의 정보를 반환한다") {
+ runTest {
+ val result = repository.getRestaurantInfo(Restaurant.HAKSIK)
+ result?.enum shouldBe Restaurant.HAKSIK
+ result?.name shouldBe "학식당"
+ result?.location shouldBe "B1"
+ }
+ }
+ }
+
+ `when`("식당 정보 JSON은 유효하지만 대상 enum이 없으면") {
+ every { remoteConfig.fetchAndActivate() } returns Tasks.forResult(true)
+ every { remoteConfig.getString("cafeteria_information") } returns """
+ [{"enum":"HAKSIK","name":"학식당","location":"B1","image":"a.png","time":"11:00-14:00","etc":"-"}]
+ """.trimIndent()
+
+ then("null을 반환한다") {
+ runTest {
+ repository.getRestaurantInfo(Restaurant.DODAM).shouldBeNull()
+ }
+ }
+ }
+
+ `when`("식당 정보 JSON 파싱에 실패하면") {
+ every { remoteConfig.fetchAndActivate() } returns Tasks.forResult(true)
+ every { remoteConfig.getString("cafeteria_information") } returns "{invalid-json}"
+
+ then("빈 리스트로 처리되어 null을 반환한다") {
+ runTest {
+ repository.getRestaurantInfo(Restaurant.HAKSIK).shouldBeNull()
+ }
+ }
+ }
+ }
+})
diff --git a/app/src/test/java/com/eatssu/android/data/remote/repository/HealthCheckRepositoryImplBehaviorSpec.kt b/app/src/test/java/com/eatssu/android/data/remote/repository/HealthCheckRepositoryImplBehaviorSpec.kt
new file mode 100644
index 000000000..6ce2f513f
--- /dev/null
+++ b/app/src/test/java/com/eatssu/android/data/remote/repository/HealthCheckRepositoryImplBehaviorSpec.kt
@@ -0,0 +1,39 @@
+package com.eatssu.android.data.remote.repository
+
+import com.eatssu.android.data.model.ApiResult
+import com.eatssu.android.data.remote.service.HealthCheckService
+import com.eatssu.android.test.AppBehaviorSpec
+import io.kotest.matchers.shouldBe
+import io.mockk.coEvery
+import io.mockk.mockk
+import kotlinx.coroutines.ExperimentalCoroutinesApi
+import kotlinx.coroutines.test.runTest
+
+@OptIn(ExperimentalCoroutinesApi::class)
+class HealthCheckRepositoryImplBehaviorSpec : AppBehaviorSpec({
+
+ given("HealthCheckRepositoryImpl") {
+ val service = mockk()
+ val repository = HealthCheckRepositoryImpl(service)
+
+ `when`("health check API가 성공하면") {
+ coEvery { service.checkHealth() } returns ApiResult.Success(Unit)
+
+ then("true를 반환한다") {
+ runTest {
+ repository.checkHealth() shouldBe true
+ }
+ }
+ }
+
+ `when`("health check API가 실패하면") {
+ coEvery { service.checkHealth() } returns ApiResult.Failure(500, "error")
+
+ then("false를 반환한다") {
+ runTest {
+ repository.checkHealth() shouldBe false
+ }
+ }
+ }
+ }
+})
diff --git a/app/src/test/java/com/eatssu/android/data/remote/repository/MealRepositoryImplBehaviorSpec.kt b/app/src/test/java/com/eatssu/android/data/remote/repository/MealRepositoryImplBehaviorSpec.kt
new file mode 100644
index 000000000..e282acf51
--- /dev/null
+++ b/app/src/test/java/com/eatssu/android/data/remote/repository/MealRepositoryImplBehaviorSpec.kt
@@ -0,0 +1,83 @@
+package com.eatssu.android.data.remote.repository
+
+import com.eatssu.android.data.model.ApiResult
+import com.eatssu.android.data.remote.dto.response.GetMealResponse
+import com.eatssu.android.data.remote.dto.response.MenusInformationList
+import com.eatssu.android.data.remote.service.MealService
+import com.eatssu.android.test.AppBehaviorSpec
+import com.eatssu.common.enums.Restaurant
+import com.eatssu.common.enums.Time
+import io.kotest.matchers.shouldBe
+import io.mockk.coEvery
+import io.mockk.mockk
+import kotlinx.coroutines.ExperimentalCoroutinesApi
+import kotlinx.coroutines.test.runTest
+
+@OptIn(ExperimentalCoroutinesApi::class)
+class MealRepositoryImplBehaviorSpec : AppBehaviorSpec({
+
+ given("MealRepositoryImpl") {
+ val mealService = mockk()
+ val repository = MealRepositoryImpl(mealService)
+
+ val mealResponse = listOf(
+ GetMealResponse(
+ mealId = 10L,
+ price = 5000,
+ rating = 4.0,
+ briefMenus = listOf(
+ MenusInformationList(menuId = 1L, name = "제육"),
+ MenusInformationList(menuId = 2L, name = "계란찜"),
+ ),
+ )
+ )
+
+ `when`("getTodayMeal API가 성공하면") {
+ coEvery { mealService.getTodayMeal("2025-01-01", "HAKSIK", "LUNCH") } returns ApiResult.Success(mealResponse)
+
+ then("메뉴 이름 리스트 리스트로 변환한다") {
+ runTest {
+ repository.getTodayMeal("2025-01-01", "HAKSIK", "LUNCH") shouldBe listOf(
+ listOf("제육", "계란찜")
+ )
+ }
+ }
+ }
+
+ `when`("getTodayMeal API가 실패하면") {
+ coEvery { mealService.getTodayMeal("2025-01-01", "HAKSIK", "LUNCH") } returns ApiResult.Failure(500, "err")
+
+ then("빈 리스트를 반환한다") {
+ runTest {
+ repository.getTodayMeal("2025-01-01", "HAKSIK", "LUNCH") shouldBe emptyList()
+ }
+ }
+ }
+
+ `when`("getTodayMenuList API가 성공하면") {
+ coEvery {
+ mealService.getTodayMeal("2025-01-01", Restaurant.HAKSIK.toString(), Time.LUNCH.toString())
+ } returns ApiResult.Success(mealResponse)
+
+ then("Menu 도메인 리스트로 변환한다") {
+ runTest {
+ val result = repository.getTodayMenuList("2025-01-01", Restaurant.HAKSIK, Time.LUNCH)
+ result.size shouldBe 1
+ result.first().name shouldBe "제육, 계란찜"
+ }
+ }
+ }
+
+ `when`("getTodayMenuList API가 실패하면") {
+ coEvery {
+ mealService.getTodayMeal("2025-01-01", Restaurant.HAKSIK.toString(), Time.LUNCH.toString())
+ } returns ApiResult.UnknownError(IllegalStateException("boom"))
+
+ then("빈 리스트를 반환한다") {
+ runTest {
+ repository.getTodayMenuList("2025-01-01", Restaurant.HAKSIK, Time.LUNCH) shouldBe emptyList()
+ }
+ }
+ }
+ }
+})
diff --git a/app/src/test/java/com/eatssu/android/data/remote/repository/MenuRepositoryImplBehaviorSpec.kt b/app/src/test/java/com/eatssu/android/data/remote/repository/MenuRepositoryImplBehaviorSpec.kt
new file mode 100644
index 000000000..84d844338
--- /dev/null
+++ b/app/src/test/java/com/eatssu/android/data/remote/repository/MenuRepositoryImplBehaviorSpec.kt
@@ -0,0 +1,62 @@
+package com.eatssu.android.data.remote.repository
+
+import com.eatssu.android.data.model.ApiResult
+import com.eatssu.android.data.remote.dto.response.CategoryMenuListCollection
+import com.eatssu.android.data.remote.dto.response.GetFixedMenuResponse
+import com.eatssu.android.data.remote.dto.response.MenuInformationList
+import com.eatssu.android.data.remote.service.MenuService
+import com.eatssu.android.test.AppBehaviorSpec
+import com.eatssu.common.enums.Restaurant
+import io.kotest.matchers.shouldBe
+import io.mockk.coEvery
+import io.mockk.mockk
+import kotlinx.coroutines.ExperimentalCoroutinesApi
+import kotlinx.coroutines.test.runTest
+
+@OptIn(ExperimentalCoroutinesApi::class)
+class MenuRepositoryImplBehaviorSpec : AppBehaviorSpec({
+
+ given("MenuRepositoryImpl") {
+ val menuService = mockk()
+ val repository = MenuRepositoryImpl(menuService)
+
+ `when`("고정 메뉴 API가 성공하면") {
+ val response = GetFixedMenuResponse(
+ categoryMenuListCollection = arrayListOf(
+ CategoryMenuListCollection(
+ category = "A",
+ menus = arrayListOf(
+ MenuInformationList(
+ menuId = 1L,
+ name = "돈까스",
+ price = 5000,
+ rating = 4.5,
+ )
+ ),
+ )
+ )
+ )
+ coEvery { menuService.getFixMenu(Restaurant.FOOD_COURT.toString()) } returns ApiResult.Success(response)
+
+ then("도메인 Menu 리스트로 매핑한다") {
+ runTest {
+ val result = repository.getFixedMenuList(Restaurant.FOOD_COURT)
+ result.size shouldBe 1
+ result.first().name shouldBe "돈까스"
+ }
+ }
+ }
+
+ `when`("고정 메뉴 API가 실패하면") {
+ coEvery {
+ menuService.getFixMenu(Restaurant.FOOD_COURT.toString())
+ } returns ApiResult.Failure(500, "err")
+
+ then("빈 리스트를 반환한다") {
+ runTest {
+ repository.getFixedMenuList(Restaurant.FOOD_COURT) shouldBe emptyList()
+ }
+ }
+ }
+ }
+})
diff --git a/app/src/test/java/com/eatssu/android/data/remote/repository/OauthRepositoryImplBehaviorSpec.kt b/app/src/test/java/com/eatssu/android/data/remote/repository/OauthRepositoryImplBehaviorSpec.kt
new file mode 100644
index 000000000..45d241fa8
--- /dev/null
+++ b/app/src/test/java/com/eatssu/android/data/remote/repository/OauthRepositoryImplBehaviorSpec.kt
@@ -0,0 +1,107 @@
+package com.eatssu.android.data.remote.repository
+
+import com.eatssu.android.data.model.ApiResult
+import com.eatssu.android.data.remote.dto.request.CheckValidTokenRequest
+import com.eatssu.android.data.remote.dto.response.TokenResponse
+import com.eatssu.android.data.remote.service.OauthService
+import com.eatssu.android.domain.model.ReissueTokenResult
+import com.eatssu.android.test.AppBehaviorSpec
+import com.eatssu.common.enums.DeviceType
+import io.kotest.matchers.shouldBe
+import io.mockk.coEvery
+import io.mockk.coVerify
+import io.mockk.mockk
+import kotlinx.coroutines.ExperimentalCoroutinesApi
+import kotlinx.coroutines.test.runTest
+import java.io.IOException
+
+@OptIn(ExperimentalCoroutinesApi::class)
+class OauthRepositoryImplBehaviorSpec : AppBehaviorSpec({
+
+ given("OauthRepositoryImpl") {
+ val oauthService = mockk()
+ val repository = OauthRepositoryImpl(oauthService)
+
+ `when`("reissueToken이 성공하면") {
+ coEvery {
+ oauthService.getNewToken("Bearer refresh-token")
+ } returns ApiResult.Success(TokenResponse("new-access", "new-refresh"))
+
+ then("Bearer prefix를 적용해 요청하고 성공 결과를 매핑한다") {
+ runTest {
+ repository.reissueToken("refresh-token") shouldBe ReissueTokenResult.Success(
+ com.eatssu.android.domain.model.Token("new-access", "new-refresh")
+ )
+ coVerify(exactly = 1) { oauthService.getNewToken("Bearer refresh-token") }
+ }
+ }
+ }
+
+ `when`("reissueToken이 HTTP 실패면") {
+ coEvery { oauthService.getNewToken("Bearer refresh-token") } returns ApiResult.Failure(401, "invalid")
+
+ then("Failure(code,message)로 변환한다") {
+ runTest {
+ repository.reissueToken("refresh-token") shouldBe ReissueTokenResult.Failure(
+ responseCode = 401,
+ message = "invalid",
+ )
+ }
+ }
+ }
+
+ `when`("reissueToken이 네트워크 오류면") {
+ val error = IOException("offline")
+ coEvery { oauthService.getNewToken("Bearer refresh-token") } returns ApiResult.NetworkError(error)
+
+ then("throwable을 담은 Failure로 변환한다") {
+ runTest {
+ repository.reissueToken("refresh-token") shouldBe ReissueTokenResult.Failure(throwable = error)
+ }
+ }
+ }
+
+ `when`("login이 성공하면") {
+ coEvery { oauthService.loginWithKakao(any()) } returns ApiResult.Success(TokenResponse("a", "r"))
+
+ then("도메인 토큰을 반환한다") {
+ runTest {
+ repository.login("a@b.com", "pid", DeviceType.ANDROID) shouldBe
+ com.eatssu.android.domain.model.Token("a", "r")
+ }
+ }
+ }
+
+ `when`("login이 실패하면") {
+ coEvery { oauthService.loginWithKakao(any()) } returns ApiResult.Failure(400, "bad")
+
+ then("null을 반환한다") {
+ runTest {
+ repository.login("a@b.com", "pid", DeviceType.ANDROID) shouldBe null
+ }
+ }
+ }
+
+ `when`("checkValidToken을 호출하면") {
+ val body = CheckValidTokenRequest("access")
+ coEvery { oauthService.checkValidToken(body) } returns ApiResult.Success(true)
+
+ then("성공값을 그대로 반환한다") {
+ runTest {
+ repository.checkValidToken(body) shouldBe true
+ }
+ }
+ }
+
+ `when`("checkValidToken이 실패하면") {
+ val body = CheckValidTokenRequest("access")
+ coEvery { oauthService.checkValidToken(body) } returns ApiResult.Failure(500, "err")
+
+ then("기본값 false를 반환한다") {
+ runTest {
+ repository.checkValidToken(body) shouldBe false
+ }
+ }
+ }
+ }
+})
diff --git a/app/src/test/java/com/eatssu/android/data/remote/repository/PartnershipRepositoryImplBehaviorSpec.kt b/app/src/test/java/com/eatssu/android/data/remote/repository/PartnershipRepositoryImplBehaviorSpec.kt
new file mode 100644
index 000000000..a07fcdda4
--- /dev/null
+++ b/app/src/test/java/com/eatssu/android/data/remote/repository/PartnershipRepositoryImplBehaviorSpec.kt
@@ -0,0 +1,136 @@
+package com.eatssu.android.data.remote.repository
+
+import com.eatssu.android.data.model.ApiResult
+import com.eatssu.android.data.remote.dto.response.PartnershipResponse
+import com.eatssu.android.data.remote.dto.response.PartnershipRestaurantResponse
+import com.eatssu.android.data.remote.service.PartnershipService
+import com.eatssu.android.data.remote.service.UserService
+import com.eatssu.android.test.AppBehaviorSpec
+import io.kotest.matchers.shouldBe
+import io.mockk.coEvery
+import io.mockk.mockk
+import kotlinx.coroutines.ExperimentalCoroutinesApi
+import kotlinx.coroutines.test.runTest
+
+@OptIn(ExperimentalCoroutinesApi::class)
+class PartnershipRepositoryImplBehaviorSpec : AppBehaviorSpec({
+
+ given("PartnershipRepositoryImpl") {
+ val partnershipService = mockk()
+ val userService = mockk()
+ val repository = PartnershipRepositoryImpl(partnershipService, userService)
+
+ `when`("전체 제휴 조회 API가 성공하면") {
+ val response = listOf(
+ PartnershipResponse(
+ storeName = "Cafe A",
+ longitude = 127.0,
+ latitude = 37.0,
+ restaurantType = "CAFE",
+ partnershipInfos = listOf(
+ PartnershipResponse.PartnershipInfo(
+ id = 1,
+ partnershipType = "DISCOUNT",
+ collegeName = "IT",
+ departmentName = "CS",
+ likeCount = 1,
+ isLiked = true,
+ description = "10% 할인",
+ startDate = "2025-01-01",
+ endDate = "2025-12-31",
+ )
+ ),
+ )
+ )
+ coEvery { partnershipService.getAllPartnerships() } returns ApiResult.Success(response)
+
+ then("도메인 Partnership 리스트를 반환한다") {
+ runTest {
+ val result = repository.getAllPartnerships()
+ result.size shouldBe 1
+ result.first().storeName shouldBe "Cafe A"
+ }
+ }
+ }
+
+ `when`("전체 제휴 조회 API가 실패하면") {
+ coEvery { partnershipService.getAllPartnerships() } returns ApiResult.Failure(500, "err")
+
+ then("빈 리스트를 반환한다") {
+ runTest {
+ repository.getAllPartnerships() shouldBe emptyList()
+ }
+ }
+ }
+
+ `when`("개별 제휴 조회 API가 성공하면") {
+ val response = PartnershipRestaurantResponse(
+ id = 1,
+ partnershipType = "DISCOUNT",
+ storeName = "Cafe A",
+ description = "10% 할인",
+ startDate = "2025-01-01",
+ endDate = "2025-12-31",
+ restaurantType = "CAFE",
+ longitude = 127.0,
+ latitude = 37.0,
+ collegeName = "IT",
+ departmentName = "CS",
+ partnershipLikeCount = 1,
+ likedByUser = true,
+ )
+ coEvery { partnershipService.getPartnershipById(1) } returns ApiResult.Success(response)
+
+ then("도메인 PartnershipRestaurant를 반환한다") {
+ runTest {
+ val result = repository.getPartnershipById(1)
+ result?.id shouldBe 1
+ result?.storeName shouldBe "Cafe A"
+ result?.description shouldBe "10% 할인"
+ result?.collegeName shouldBe "IT"
+ }
+ }
+ }
+
+ `when`("개별 제휴 조회 API가 실패하면") {
+ coEvery { partnershipService.getPartnershipById(1) } returns ApiResult.UnknownError(IllegalStateException("boom"))
+
+ then("null을 반환한다") {
+ runTest {
+ repository.getPartnershipById(1) shouldBe null
+ }
+ }
+ }
+
+ `when`("유저 학과 제휴 조회가 실패하면") {
+ coEvery { userService.getUserDepartmentPartnerships() } returns ApiResult.Failure(500, "err")
+
+ then("빈 리스트를 반환한다") {
+ runTest {
+ repository.getUserCollegePartnerships() shouldBe emptyList()
+ }
+ }
+ }
+
+ `when`("유저 학과 제휴 조회가 성공하면") {
+ val response = listOf(
+ PartnershipResponse(
+ storeName = "Cafe B",
+ longitude = 127.0,
+ latitude = 37.0,
+ restaurantType = "CAFE",
+ partnershipInfos = emptyList(),
+ )
+ )
+ coEvery { userService.getUserDepartmentPartnerships() } returns ApiResult.Success(response)
+
+ then("도메인 Partnership 리스트를 반환한다") {
+ runTest {
+ val result = repository.getUserCollegePartnerships()
+ result.size shouldBe 1
+ result.first().storeName shouldBe "Cafe B"
+ }
+ }
+ }
+ }
+})
diff --git a/app/src/test/java/com/eatssu/android/data/remote/repository/ReportRepositoryImplBehaviorSpec.kt b/app/src/test/java/com/eatssu/android/data/remote/repository/ReportRepositoryImplBehaviorSpec.kt
new file mode 100644
index 000000000..bbec35b21
--- /dev/null
+++ b/app/src/test/java/com/eatssu/android/data/remote/repository/ReportRepositoryImplBehaviorSpec.kt
@@ -0,0 +1,45 @@
+package com.eatssu.android.data.remote.repository
+
+import com.eatssu.android.data.model.ApiResult
+import com.eatssu.android.data.remote.dto.request.ReportRequest
+import com.eatssu.android.data.remote.service.ReportService
+import com.eatssu.android.test.AppBehaviorSpec
+import io.kotest.matchers.shouldBe
+import io.mockk.coEvery
+import io.mockk.mockk
+import kotlinx.coroutines.ExperimentalCoroutinesApi
+import kotlinx.coroutines.test.runTest
+
+@OptIn(ExperimentalCoroutinesApi::class)
+class ReportRepositoryImplBehaviorSpec : AppBehaviorSpec({
+
+ given("ReportRepositoryImpl") {
+ val service = mockk()
+ val repository = ReportRepositoryImpl(service)
+ val request = ReportRequest(
+ reviewId = 1L,
+ reportType = "SPAM",
+ content = "신고 사유",
+ )
+
+ `when`("신고 API가 성공하면") {
+ coEvery { service.reportReview(request) } returns ApiResult.Success(Unit)
+
+ then("true를 반환한다") {
+ runTest {
+ repository.reportReview(request) shouldBe true
+ }
+ }
+ }
+
+ `when`("신고 API가 실패하면") {
+ coEvery { service.reportReview(request) } returns ApiResult.UnknownError(IllegalStateException("boom"))
+
+ then("false를 반환한다") {
+ runTest {
+ repository.reportReview(request) shouldBe false
+ }
+ }
+ }
+ }
+})
diff --git a/app/src/test/java/com/eatssu/android/data/remote/repository/ReviewRepositoryImplBehaviorSpec.kt b/app/src/test/java/com/eatssu/android/data/remote/repository/ReviewRepositoryImplBehaviorSpec.kt
new file mode 100644
index 000000000..791f6605d
--- /dev/null
+++ b/app/src/test/java/com/eatssu/android/data/remote/repository/ReviewRepositoryImplBehaviorSpec.kt
@@ -0,0 +1,380 @@
+package com.eatssu.android.data.remote.repository
+
+import com.eatssu.android.data.model.ApiResult
+import com.eatssu.android.data.remote.dto.request.ModifyReviewRequest
+import com.eatssu.android.data.remote.dto.request.WriteMealReviewRequest
+import com.eatssu.android.data.remote.dto.request.WriteMenuReviewRequest
+import com.eatssu.android.data.remote.dto.response.ImageResponse
+import com.eatssu.android.data.remote.dto.response.MealReviewInfoResponse
+import com.eatssu.android.data.remote.dto.response.MenuList
+import com.eatssu.android.data.remote.dto.response.MenuOfMealResponse
+import com.eatssu.android.data.remote.dto.response.MenuReviewInfoResponse
+import com.eatssu.android.data.remote.dto.response.MyReviewListResponse
+import com.eatssu.android.data.remote.service.ReviewService
+import com.eatssu.android.domain.model.Review
+import com.eatssu.android.test.AppBehaviorSpec
+import io.kotest.assertions.throwables.shouldThrow
+import io.kotest.matchers.collections.shouldHaveSize
+import io.kotest.matchers.shouldBe
+import io.mockk.coEvery
+import io.mockk.coVerify
+import io.mockk.every
+import io.mockk.mockk
+import io.mockk.slot
+import kotlinx.coroutines.ExperimentalCoroutinesApi
+import kotlinx.coroutines.test.runTest
+import java.io.File
+import java.io.IOException
+
+@OptIn(ExperimentalCoroutinesApi::class)
+class ReviewRepositoryImplBehaviorSpec : AppBehaviorSpec({
+
+ given("ReviewRepositoryImpl") {
+ val service = mockk()
+ val repository = ReviewRepositoryImpl(service)
+
+ `when`("writeMealReview를 호출하면") {
+ val requestSlot = slot()
+ coEvery { service.writeMealReview(capture(requestSlot)) } returns ApiResult.Success(Unit)
+
+ then("요청 바디를 menuLikes로 매핑하고 성공 true를 반환한다") {
+ runTest {
+ val result = repository.writeMealReview(
+ mealId = 10L,
+ rating = 4,
+ content = "맛있어요",
+ imageUrls = listOf("https://img"),
+ likeMenuIdList = listOf(1L, 2L),
+ )
+
+ result shouldBe true
+ requestSlot.captured.mealId shouldBe 10L
+ requestSlot.captured.rating shouldBe 4
+ requestSlot.captured.content shouldBe "맛있어요"
+ requestSlot.captured.imageUrls shouldBe listOf("https://img")
+ requestSlot.captured.menuLikes shouldBe listOf(
+ WriteMealReviewRequest.MenuLikes(menuId = 1L, isLike = true),
+ WriteMealReviewRequest.MenuLikes(menuId = 2L, isLike = true),
+ )
+ }
+ }
+ }
+
+ `when`("writeMealReview에서 likeMenuIdList가 null이면") {
+ val requestSlot = slot()
+ coEvery { service.writeMealReview(capture(requestSlot)) } returns ApiResult.Success(Unit)
+
+ then("menuLikes=null로 전달한다") {
+ runTest {
+ repository.writeMealReview(
+ mealId = 1L,
+ rating = 5,
+ content = "content",
+ imageUrls = emptyList(),
+ likeMenuIdList = null,
+ )
+ requestSlot.captured.menuLikes shouldBe null
+ }
+ }
+ }
+
+ `when`("writeMealReview API가 실패하면") {
+ coEvery { service.writeMealReview(any()) } returns ApiResult.Failure(400, "bad")
+
+ then("false를 반환한다") {
+ runTest {
+ repository.writeMealReview(1L, 5, "x", emptyList(), listOf(1L)) shouldBe false
+ }
+ }
+ }
+
+ `when`("writeMenuReview를 호출하면") {
+ val requestSlot = slot()
+ coEvery { service.writeMenuReview(capture(requestSlot)) } returns ApiResult.Success(Unit)
+
+ then("첫 번째 likeMenuId만 menuLike로 매핑한다") {
+ runTest {
+ repository.writeMenuReview(
+ rating = 3,
+ content = "메뉴리뷰",
+ imageUrls = listOf("img"),
+ likeMenuIdList = listOf(9L, 10L),
+ ) shouldBe true
+
+ requestSlot.captured.rating shouldBe 3
+ requestSlot.captured.menuLike shouldBe WriteMenuReviewRequest.MenuLike(
+ menuId = 9L,
+ isLike = true,
+ )
+ requestSlot.captured.imageUrls shouldBe listOf("img")
+ }
+ }
+ }
+
+ `when`("writeMenuReview에서 likeMenuIdList가 null이면") {
+ val requestSlot = slot()
+ coEvery { service.writeMenuReview(capture(requestSlot)) } returns ApiResult.Success(Unit)
+
+ then("menuLike=null로 전달한다") {
+ runTest {
+ repository.writeMenuReview(
+ rating = 2,
+ content = "x",
+ imageUrls = emptyList(),
+ likeMenuIdList = null,
+ )
+ requestSlot.captured.menuLike shouldBe null
+ }
+ }
+ }
+
+ `when`("writeMenuReview에서 likeMenuIdList가 빈 리스트면") {
+ then("현재 구현 그대로 NoSuchElementException이 발생한다") {
+ runTest {
+ shouldThrow {
+ repository.writeMenuReview(
+ rating = 1,
+ content = "x",
+ imageUrls = emptyList(),
+ likeMenuIdList = emptyList(),
+ )
+ }
+ }
+ }
+ }
+
+ `when`("deleteReview API 결과가 성공이면") {
+ coEvery { service.deleteReview(100L) } returns ApiResult.Success(Unit)
+
+ then("true를 반환한다") {
+ runTest {
+ repository.deleteReview(100L) shouldBe true
+ }
+ }
+ }
+
+ `when`("deleteReview API 결과가 실패면") {
+ coEvery { service.deleteReview(100L) } returns ApiResult.UnknownError(IllegalStateException("boom"))
+
+ then("false를 반환한다") {
+ runTest {
+ repository.deleteReview(100L) shouldBe false
+ }
+ }
+ }
+
+ `when`("modifyReview를 호출하면") {
+ val requestSlot = slot()
+ coEvery { service.modifyReview(7L, capture(requestSlot)) } returns ApiResult.Success(Unit)
+
+ then("menuLikeInfoList를 요청 DTO로 매핑한다") {
+ runTest {
+ val menuLikeInfo = listOf(
+ Review.MenuLikeInfo(menuId = 1L, name = "A", isLike = true),
+ Review.MenuLikeInfo(menuId = 2L, name = "B", isLike = false),
+ )
+ repository.modifyReview(
+ reviewId = 7L,
+ rating = 5,
+ content = "수정",
+ menuLikeInfoList = menuLikeInfo,
+ ) shouldBe true
+
+ requestSlot.captured.rating shouldBe 5
+ requestSlot.captured.content shouldBe "수정"
+ requestSlot.captured.menuLikes shouldBe listOf(
+ ModifyReviewRequest.MenuLikes(menuId = 1L, isLike = true),
+ ModifyReviewRequest.MenuLikes(menuId = 2L, isLike = false),
+ )
+ }
+ }
+ }
+
+ `when`("modifyReview API 결과가 실패면") {
+ coEvery { service.modifyReview(any(), any()) } returns ApiResult.Failure(500, "error")
+
+ then("false를 반환한다") {
+ runTest {
+ repository.modifyReview(
+ reviewId = 1L,
+ rating = 1,
+ content = "x",
+ menuLikeInfoList = emptyList(),
+ ) shouldBe false
+ }
+ }
+ }
+
+ `when`("getMealReviewInfo API가 성공하면") {
+ coEvery { service.getMealReviewInfo(3L) } returns ApiResult.Success(
+ MealReviewInfoResponse(
+ totalReviewCount = 12,
+ rating = 4.46,
+ reviewRatingCount = MealReviewInfoResponse.ReviewRatingCount(
+ oneStarCount = 1,
+ twoStarCount = 2,
+ threeStarCount = 3,
+ fourStarCount = 4,
+ fiveStarCount = 5,
+ ),
+ )
+ )
+
+ then("도메인 ReviewInfo로 변환한다") {
+ runTest {
+ val result = repository.getMealReviewInfo(3L)
+ result?.reviewCnt shouldBe 12
+ result?.rating shouldBe 4.5
+ result?.oneStarCount shouldBe 1
+ result?.fiveStarCount shouldBe 5
+ }
+ }
+ }
+
+ `when`("getMealReviewInfo API가 실패하면") {
+ coEvery { service.getMealReviewInfo(3L) } returns ApiResult.Failure(404, "not found")
+
+ then("null을 반환한다") {
+ runTest {
+ repository.getMealReviewInfo(3L) shouldBe null
+ }
+ }
+ }
+
+ `when`("getMenuReviewInfo API가 성공하면") {
+ coEvery { service.getMenuReviewInfo(4L) } returns ApiResult.Success(
+ MenuReviewInfoResponse(
+ totalReviewCount = 3,
+ rating = 3.24,
+ reviewRatingCount = null,
+ )
+ )
+
+ then("도메인 ReviewInfo로 변환하며 null 카운트는 0으로 채운다") {
+ runTest {
+ val result = repository.getMenuReviewInfo(4L)
+ result?.reviewCnt shouldBe 3
+ result?.rating shouldBe 3.2
+ result?.oneStarCount shouldBe 0
+ result?.fiveStarCount shouldBe 0
+ }
+ }
+ }
+
+ `when`("getMenuReviewInfo API가 실패하면") {
+ coEvery { service.getMenuReviewInfo(4L) } returns ApiResult.NetworkError(IOException("offline"))
+
+ then("null을 반환한다") {
+ runTest {
+ repository.getMenuReviewInfo(4L) shouldBe null
+ }
+ }
+ }
+
+ `when`("getImageString API가 성공하면") {
+ coEvery { service.uploadImage(any()) } returns ApiResult.Success(ImageResponse(url = "https://img"))
+
+ then("업로드된 이미지 URL을 반환한다") {
+ runTest {
+ val file = File.createTempFile("review", ".jpg").apply { writeText("x") }
+ try {
+ repository.getImageString(file) shouldBe "https://img"
+ } finally {
+ file.delete()
+ }
+ coVerify(exactly = 1) { service.uploadImage(any()) }
+ }
+ }
+ }
+
+ `when`("getImageString API가 실패하면") {
+ coEvery { service.uploadImage(any()) } returns ApiResult.Failure(500, "error")
+
+ then("null을 반환한다") {
+ runTest {
+ val file = File.createTempFile("review", ".jpg").apply { writeText("x") }
+ try {
+ repository.getImageString(file) shouldBe null
+ } finally {
+ file.delete()
+ }
+ }
+ }
+ }
+
+ `when`("getValidMenusByMealId API가 성공하면") {
+ coEvery { service.getMenuInfoByMealId(8L) } returns ApiResult.Success(
+ MenuOfMealResponse(
+ menuList = arrayListOf(
+ MenuList(menuId = 11L, name = "돈까스"),
+ MenuList(menuId = null, name = null),
+ )
+ )
+ )
+
+ then("MenuMini 리스트로 변환하며 null 필드는 기본값으로 매핑한다") {
+ runTest {
+ val result = repository.getValidMenusByMealId(8L)
+ result shouldHaveSize 2
+ result[0].id shouldBe 11L
+ result[0].name shouldBe "돈까스"
+ result[1].id shouldBe -1L
+ result[1].name shouldBe ""
+ }
+ }
+ }
+
+ `when`("getValidMenusByMealId API가 실패하면") {
+ coEvery { service.getMenuInfoByMealId(8L) } returns ApiResult.Failure(500, "error")
+
+ then("빈 리스트를 반환한다") {
+ runTest {
+ repository.getValidMenusByMealId(8L) shouldBe emptyList()
+ }
+ }
+ }
+
+ `when`("getMyReviews API가 성공하면") {
+ coEvery { service.getMyReviews() } returns ApiResult.Success(
+ MyReviewListResponse(
+ dataList = arrayListOf(
+ MyReviewListResponse.DataList(
+ reviewId = 15L,
+ rating = 5,
+ writtenAt = "2025-01-01",
+ content = "great",
+ imageUrls = arrayListOf("https://img"),
+ menuList = arrayListOf(
+ MyReviewListResponse.DataList.MenuList(
+ id = 7L,
+ name = "제육",
+ isLike = true,
+ )
+ ),
+ )
+ )
+ )
+ )
+
+ then("Review 리스트로 매핑한다") {
+ runTest {
+ val result = repository.getMyReviews()
+ result shouldHaveSize 1
+ result.first().reviewId shouldBe 15L
+ result.first().menuLikeInfoList.first().menuId shouldBe 7L
+ result.first().imgUrl shouldBe "https://img"
+ }
+ }
+ }
+
+ `when`("getMyReviews API가 실패하면") {
+ coEvery { service.getMyReviews() } returns ApiResult.UnknownError(IllegalStateException("boom"))
+
+ then("빈 리스트를 반환한다") {
+ runTest {
+ repository.getMyReviews() shouldBe emptyList()
+ }
+ }
+ }
+ }
+})
diff --git a/app/src/test/java/com/eatssu/android/data/remote/repository/UserRepositoryImplBehaviorSpec.kt b/app/src/test/java/com/eatssu/android/data/remote/repository/UserRepositoryImplBehaviorSpec.kt
new file mode 100644
index 000000000..776889db9
--- /dev/null
+++ b/app/src/test/java/com/eatssu/android/data/remote/repository/UserRepositoryImplBehaviorSpec.kt
@@ -0,0 +1,253 @@
+package com.eatssu.android.data.remote.repository
+
+import com.eatssu.android.data.model.ApiResult
+import com.eatssu.android.data.remote.dto.request.ChangeNicknameRequest
+import com.eatssu.android.data.remote.dto.response.CollegeResponse
+import com.eatssu.android.data.remote.dto.response.DepartmentResponse
+import com.eatssu.android.data.remote.dto.response.MyNickNameResponse
+import com.eatssu.android.data.remote.dto.response.UserCollegeDepartmentResponse
+import com.eatssu.android.data.remote.service.UserService
+import com.eatssu.android.test.AppBehaviorSpec
+import io.kotest.matchers.collections.shouldHaveSize
+import io.kotest.matchers.shouldBe
+import io.mockk.coEvery
+import io.mockk.mockk
+import kotlinx.coroutines.ExperimentalCoroutinesApi
+import kotlinx.coroutines.test.runTest
+
+@OptIn(ExperimentalCoroutinesApi::class)
+class UserRepositoryImplBehaviorSpec : AppBehaviorSpec({
+
+ given("UserRepositoryImpl") {
+ val userService = mockk()
+ val repository = UserRepositoryImpl(userService)
+
+ `when`("닉네임 변경이 성공하면") {
+ coEvery { userService.changeNickname(ChangeNicknameRequest("new")) } returns ApiResult.Success(Unit)
+
+ then("Result.success를 반환한다") {
+ runTest {
+ repository.updateUserName(ChangeNicknameRequest("new")).isSuccess shouldBe true
+ }
+ }
+ }
+
+ `when`("닉네임 변경이 Failure면") {
+ coEvery {
+ userService.changeNickname(ChangeNicknameRequest("new"))
+ } returns ApiResult.Failure(400, "bad nickname")
+
+ then("서버 메시지를 포함한 실패 Result를 반환한다") {
+ runTest {
+ val result = repository.updateUserName(ChangeNicknameRequest("new"))
+ result.isFailure shouldBe true
+ result.exceptionOrNull()?.message shouldBe "bad nickname"
+ }
+ }
+ }
+
+ `when`("닉네임 변경이 Failure지만 메시지가 없으면") {
+ coEvery {
+ userService.changeNickname(ChangeNicknameRequest("new"))
+ } returns ApiResult.Failure(400, null)
+
+ then("기본 실패 메시지를 반환한다") {
+ runTest {
+ val result = repository.updateUserName(ChangeNicknameRequest("new"))
+ result.isFailure shouldBe true
+ result.exceptionOrNull()?.message shouldBe "닉네임 변경에 실패했어요."
+ }
+ }
+ }
+
+ `when`("닉네임 변경이 UnknownError면") {
+ coEvery {
+ userService.changeNickname(ChangeNicknameRequest("new"))
+ } returns ApiResult.UnknownError(IllegalStateException("boom"))
+
+ then("기본 실패 메시지를 반환한다") {
+ runTest {
+ val result = repository.updateUserName(ChangeNicknameRequest("new"))
+ result.isFailure shouldBe true
+ result.exceptionOrNull()?.message shouldBe "닉네임 변경에 실패했어요."
+ }
+ }
+ }
+
+ `when`("닉네임 중복검사에서 true를 받으면") {
+ coEvery { userService.checkNickname("ok") } returns ApiResult.Success(true)
+
+ then("성공 Result를 반환한다") {
+ runTest {
+ repository.checkUserNameValidation("ok").isSuccess shouldBe true
+ }
+ }
+ }
+
+ `when`("닉네임 중복검사에서 false를 받으면") {
+ coEvery { userService.checkNickname("dup") } returns ApiResult.Success(false)
+
+ then("중복 메시지로 실패 Result를 반환한다") {
+ runTest {
+ val result = repository.checkUserNameValidation("dup")
+ result.isFailure shouldBe true
+ result.exceptionOrNull()?.message shouldBe "이미 사용 중인 닉네임이에요."
+ }
+ }
+ }
+
+ `when`("닉네임 중복검사가 Failure이고 메시지가 없으면") {
+ coEvery { userService.checkNickname("bad") } returns ApiResult.Failure(400, null)
+
+ then("기본 검증 실패 메시지를 반환한다") {
+ runTest {
+ val result = repository.checkUserNameValidation("bad")
+ result.isFailure shouldBe true
+ result.exceptionOrNull()?.message shouldBe "올바르지 않은 닉네임이에요."
+ }
+ }
+ }
+
+ `when`("닉네임 중복검사가 UnknownError면") {
+ coEvery { userService.checkNickname("bad") } returns ApiResult.UnknownError(IllegalStateException("boom"))
+
+ then("기본 검증 실패 메시지를 반환한다") {
+ runTest {
+ val result = repository.checkUserNameValidation("bad")
+ result.isFailure shouldBe true
+ result.exceptionOrNull()?.message shouldBe "올바르지 않은 닉네임이에요."
+ }
+ }
+ }
+
+ `when`("내 닉네임 조회가 실패하면") {
+ coEvery { userService.getMyInfo() } returns ApiResult.Failure(500, "err")
+
+ then("빈 문자열을 반환한다") {
+ runTest {
+ repository.getUserNickName() shouldBe ""
+ }
+ }
+ }
+
+ `when`("단과대/학과 목록 조회 시 null 데이터가 포함되면") {
+ coEvery { userService.getCollegeList() } returns ApiResult.Success(
+ listOf(
+ CollegeResponse(1, "IT"),
+ CollegeResponse(null, "invalid"),
+ )
+ )
+ coEvery { userService.getDepartmentsByCollege(1) } returns ApiResult.Success(
+ listOf(
+ DepartmentResponse(11, "컴퓨터학부"),
+ DepartmentResponse(12, null),
+ )
+ )
+
+ then("mapNotNull로 유효 데이터만 반환한다") {
+ runTest {
+ val colleges = repository.getTotalColleges()
+ colleges shouldHaveSize 1
+ colleges.first().collegeName shouldBe "IT"
+
+ val departments = repository.getTotalDepartments(1)
+ departments shouldHaveSize 1
+ departments.first().departmentName shouldBe "컴퓨터학부"
+ }
+ }
+ }
+
+ `when`("유저 단과대/학과 조회가 성공하면") {
+ coEvery { userService.getUserCollegeDepartment() } returns ApiResult.Success(
+ UserCollegeDepartmentResponse(
+ departmentId = 11,
+ departmentName = "컴퓨터학부",
+ collegeId = 1,
+ collegeName = "IT",
+ )
+ )
+
+ then("도메인 Pair로 변환해 반환한다") {
+ runTest {
+ val result = repository.getUserCollegeDepartment()
+ result?.first?.collegeName shouldBe "IT"
+ result?.second?.departmentName shouldBe "컴퓨터학부"
+ }
+ }
+ }
+
+ `when`("유저 학과 설정 요청이 성공하면") {
+ coEvery { userService.setUserDepartment(any()) } returns ApiResult.Success(Unit)
+
+ then("true를 반환한다") {
+ runTest {
+ repository.setUserDepartment(11) shouldBe true
+ }
+ }
+ }
+
+ `when`("유저 학과 설정 요청이 실패하면") {
+ coEvery { userService.setUserDepartment(any()) } returns ApiResult.Failure(500, "err")
+
+ then("false를 반환한다") {
+ runTest {
+ repository.setUserDepartment(11) shouldBe false
+ }
+ }
+ }
+
+ `when`("회원 탈퇴 요청이 실패하면") {
+ coEvery { userService.signOut() } returns ApiResult.UnknownError(IllegalStateException("boom"))
+
+ then("기본값 false를 반환한다") {
+ runTest {
+ repository.signOut() shouldBe false
+ }
+ }
+ }
+
+ `when`("회원 탈퇴 요청이 성공하면") {
+ coEvery { userService.signOut() } returns ApiResult.Success(true)
+
+ then("true를 반환한다") {
+ runTest {
+ repository.signOut() shouldBe true
+ }
+ }
+ }
+
+ `when`("내 닉네임 조회가 성공하지만 nickname이 null이면") {
+ coEvery { userService.getMyInfo() } returns ApiResult.Success(
+ MyNickNameResponse(nickname = null, provider = "KAKAO")
+ )
+
+ then("빈 문자열을 반환한다") {
+ runTest {
+ repository.getUserNickName() shouldBe ""
+ }
+ }
+ }
+
+ `when`("단과대/학과 목록 조회 API가 실패하면") {
+ coEvery { userService.getCollegeList() } returns ApiResult.Failure(500, "err")
+ coEvery { userService.getDepartmentsByCollege(1) } returns ApiResult.UnknownError(IllegalStateException("boom"))
+
+ then("둘 다 빈 리스트를 반환한다") {
+ runTest {
+ repository.getTotalColleges() shouldBe emptyList()
+ repository.getTotalDepartments(1) shouldBe emptyList()
+ }
+ }
+ }
+
+ `when`("유저 단과대/학과 조회 API가 실패하면") {
+ coEvery { userService.getUserCollegeDepartment() } returns ApiResult.Failure(404, "not found")
+
+ then("null을 반환한다") {
+ runTest {
+ repository.getUserCollegeDepartment() shouldBe null
+ }
+ }
+ }
+ }
+})
diff --git a/app/src/test/java/com/eatssu/android/di/network/ApiResultCallAdapterBehaviorSpec.kt b/app/src/test/java/com/eatssu/android/di/network/ApiResultCallAdapterBehaviorSpec.kt
new file mode 100644
index 000000000..471e7a791
--- /dev/null
+++ b/app/src/test/java/com/eatssu/android/di/network/ApiResultCallAdapterBehaviorSpec.kt
@@ -0,0 +1,45 @@
+package com.eatssu.android.di.network
+
+import com.eatssu.android.data.model.ApiResult
+import com.eatssu.android.data.remote.dto.response.BaseResponse
+import com.eatssu.android.test.AppBehaviorSpec
+import io.kotest.matchers.shouldBe
+import io.mockk.mockk
+import retrofit2.Call
+import java.lang.reflect.ParameterizedType
+import java.lang.reflect.Type
+
+class ApiResultCallAdapterBehaviorSpec : AppBehaviorSpec({
+
+ given("ApiResultCallAdapter") {
+ fun parameterizedType(rawType: Type, vararg args: Type): ParameterizedType =
+ object : ParameterizedType {
+ override fun getRawType(): Type = rawType
+ override fun getActualTypeArguments(): Array = arrayOf(*args)
+ override fun getOwnerType(): Type? = null
+ }
+
+ val baseResponseType = parameterizedType(BaseResponse::class.java, String::class.java)
+ val adapter = ApiResultCallAdapter(
+ baseResponseType = baseResponseType,
+ dataType = String::class.java,
+ )
+
+ `when`("responseType을 조회하면") {
+ then("생성 시 전달된 baseResponseType을 그대로 반환한다") {
+ adapter.responseType() shouldBe baseResponseType
+ }
+ }
+
+ `when`("Call>를 adapt하면") {
+ val call = mockk>>(relaxed = true)
+
+ then("ApiResultCall로 감싸서 반환한다") {
+ val adapted = adapter.adapt(call)
+
+ (adapted is ApiResultCall<*>) shouldBe true
+ (adapted as Call>).request() shouldBe call.request()
+ }
+ }
+ }
+})
diff --git a/app/src/test/java/com/eatssu/android/di/network/ApiResultCallAdapterFactoryBehaviorSpec.kt b/app/src/test/java/com/eatssu/android/di/network/ApiResultCallAdapterFactoryBehaviorSpec.kt
new file mode 100644
index 000000000..12733644c
--- /dev/null
+++ b/app/src/test/java/com/eatssu/android/di/network/ApiResultCallAdapterFactoryBehaviorSpec.kt
@@ -0,0 +1,100 @@
+package com.eatssu.android.di.network
+
+import com.eatssu.android.data.model.ApiResult
+import com.eatssu.android.data.remote.dto.response.BaseResponse
+import com.eatssu.android.test.AppBehaviorSpec
+import io.kotest.assertions.throwables.shouldThrow
+import io.kotest.matchers.nulls.shouldBeNull
+import io.kotest.matchers.shouldBe
+import io.mockk.mockk
+import retrofit2.Call
+import retrofit2.Retrofit
+import java.lang.reflect.ParameterizedType
+import java.lang.reflect.Type
+
+class ApiResultCallAdapterFactoryBehaviorSpec : AppBehaviorSpec({
+
+ given("ApiResultCallAdapterFactory") {
+ val factory = ApiResultCallAdapterFactory()
+ val retrofit = mockk()
+
+ fun parameterizedType(rawType: Type, vararg args: Type): ParameterizedType =
+ object : ParameterizedType {
+ override fun getRawType(): Type = rawType
+ override fun getActualTypeArguments(): Array = arrayOf(*args)
+ override fun getOwnerType(): Type? = null
+ }
+
+ `when`("return type이 ApiResult 자체면") {
+ then("suspend 선언 누락 예외를 던진다") {
+ shouldThrow {
+ factory.get(
+ parameterizedType(ApiResult::class.java, String::class.java),
+ emptyArray(),
+ retrofit,
+ )
+ }
+ }
+ }
+
+ `when`("return type raw type이 Call이 아니면") {
+ then("adapter를 만들지 않고 null을 반환한다") {
+ factory.get(String::class.java, emptyArray(), retrofit).shouldBeNull()
+ }
+ }
+
+ `when`("return type이 raw Call이면") {
+ then("ApiResult 형태가 아니라는 예외를 던진다") {
+ shouldThrow {
+ factory.get(Call::class.java, emptyArray(), retrofit)
+ }
+ }
+ }
+
+ `when`("Call 내부 타입이 ApiResult가 아니면") {
+ then("adapter를 만들지 않고 null을 반환한다") {
+ val returnType = parameterizedType(Call::class.java, String::class.java)
+ factory.get(returnType, emptyArray(), retrofit).shouldBeNull()
+ }
+ }
+
+ `when`("Call처럼 success 타입 파라미터가 빠지면") {
+ then("ApiResult 형태가 아니라는 예외를 던진다") {
+ val returnType = parameterizedType(
+ Call::class.java,
+ ApiResult::class.java,
+ )
+
+ shouldThrow {
+ factory.get(returnType, emptyArray(), retrofit)
+ }
+ }
+ }
+
+ `when`("Call>이면") {
+ then("ApiResultCallAdapter를 반환하고 BaseResponse responseType을 구성한다") {
+ val returnType = parameterizedType(
+ Call::class.java,
+ parameterizedType(ApiResult::class.java, String::class.java),
+ )
+
+ val adapter = factory.get(returnType, emptyArray(), retrofit)
+ (adapter is ApiResultCallAdapter<*>) shouldBe true
+
+ val responseType = (adapter as ApiResultCallAdapter).responseType() as ParameterizedType
+ responseType.rawType shouldBe BaseResponse::class.java
+ responseType.actualTypeArguments.single() shouldBe String::class.java
+ }
+ }
+
+ `when`("createCallAdapter를 직접 호출하면") {
+ then("지정한 successType으로 responseType을 생성한다") {
+ val adapter = factory.createCallAdapter(Int::class.java)
+ val responseType = adapter.responseType() as ParameterizedType
+
+ responseType.rawType shouldBe BaseResponse::class.java
+ responseType.actualTypeArguments.single() shouldBe Int::class.java
+ }
+ }
+ }
+})
diff --git a/app/src/test/java/com/eatssu/android/di/network/ApiResultCallBehaviorSpec.kt b/app/src/test/java/com/eatssu/android/di/network/ApiResultCallBehaviorSpec.kt
new file mode 100644
index 000000000..9bee3abf0
--- /dev/null
+++ b/app/src/test/java/com/eatssu/android/di/network/ApiResultCallBehaviorSpec.kt
@@ -0,0 +1,210 @@
+package com.eatssu.android.di.network
+
+import app.cash.turbine.test
+import com.eatssu.android.data.model.ApiResult
+import com.eatssu.android.data.remote.dto.response.BaseResponse
+import com.eatssu.android.presentation.base.NetworkErrorEventBus
+import com.eatssu.android.test.AppBehaviorSpec
+import io.kotest.matchers.shouldBe
+import io.mockk.mockk
+import okhttp3.Request
+import okhttp3.ResponseBody.Companion.toResponseBody
+import okio.Timeout
+import retrofit2.Call
+import retrofit2.Callback
+import retrofit2.Response
+import java.io.IOException
+import java.lang.reflect.Type
+
+private class FakeBaseResponseCall(
+ private val requestValue: Request = Request.Builder().url("https://example.com").build(),
+ private val enqueueBlock: (Callback>) -> Unit,
+) : Call> {
+ private var executed = false
+ private var canceled = false
+
+ override fun enqueue(callback: Callback>) {
+ executed = true
+ enqueueBlock(callback)
+ }
+
+ override fun isExecuted(): Boolean = executed
+ override fun clone(): Call> = FakeBaseResponseCall(requestValue, enqueueBlock)
+ override fun isCanceled(): Boolean = canceled
+ override fun cancel() {
+ canceled = true
+ }
+
+ override fun execute(): Response> {
+ throw UnsupportedOperationException("Fake call supports only enqueue")
+ }
+
+ override fun request(): Request = requestValue
+ override fun timeout(): Timeout = Timeout.NONE
+}
+
+private fun ApiResultCall.enqueueAndGet(): ApiResult {
+ var result: ApiResult? = null
+ enqueue(
+ object : Callback> {
+ override fun onResponse(call: Call>, response: Response>) {
+ result = response.body()
+ }
+
+ override fun onFailure(call: Call>, t: Throwable) = Unit
+ }
+ )
+ return result ?: error("ApiResult not emitted")
+}
+
+class ApiResultCallBehaviorSpec : AppBehaviorSpec({
+
+ given("ApiResultCall") {
+ `when`("HTTP 에러 + BaseResponse 에러바디 파싱 성공") {
+ val errorJson = """{"isSuccess":false,"code":1234,"message":"bad request"}"""
+ val origin = FakeBaseResponseCall {
+ val retrofitCall = mockk>>(relaxed = true)
+ it.onResponse(
+ retrofitCall,
+ Response.error(400, errorJson.toResponseBody())
+ )
+ }
+ val apiResultCall = ApiResultCall(origin, String::class.java)
+
+ then("파싱된 code/message로 Failure를 반환한다") {
+ val result = apiResultCall.enqueueAndGet() as ApiResult.Failure
+ result.responseCode shouldBe 1234
+ result.message shouldBe "bad request"
+ }
+ }
+
+ `when`("HTTP 에러 + 에러바디 파싱 실패") {
+ val origin = FakeBaseResponseCall {
+ val retrofitCall = mockk>>(relaxed = true)
+ it.onResponse(
+ retrofitCall,
+ Response.error(500, "not-json".toResponseBody())
+ )
+ }
+ val apiResultCall = ApiResultCall(origin, String::class.java)
+
+ then("HTTP code와 raw 에러 문자열로 Failure를 반환한다") {
+ val result = apiResultCall.enqueueAndGet() as ApiResult.Failure
+ result.responseCode shouldBe 500
+ result.message shouldBe "not-json"
+ }
+ }
+
+ `when`("HTTP 성공이지만 body가 null이면") {
+ val origin = FakeBaseResponseCall {
+ val retrofitCall = mockk>>(relaxed = true)
+ @Suppress("UNCHECKED_CAST")
+ it.onResponse(
+ retrofitCall,
+ Response.success(null) as Response>
+ )
+ }
+ val apiResultCall = ApiResultCall(origin, String::class.java)
+
+ then("UnknownError를 반환한다") {
+ val result = apiResultCall.enqueueAndGet()
+ (result is ApiResult.UnknownError) shouldBe true
+ }
+ }
+
+ `when`("API 레벨에서 isSuccess=false이면") {
+ val origin = FakeBaseResponseCall {
+ val retrofitCall = mockk>>(relaxed = true)
+ it.onResponse(
+ retrofitCall,
+ Response.success(BaseResponse(isSuccess = false, code = 2001, message = "api fail"))
+ )
+ }
+ val apiResultCall = ApiResultCall(origin, String::class.java)
+
+ then("Failure(code,message)를 반환한다") {
+ val result = apiResultCall.enqueueAndGet() as ApiResult.Failure
+ result.responseCode shouldBe 2001
+ result.message shouldBe "api fail"
+ }
+ }
+
+ `when`("responseType이 Unit이면") {
+ val origin = FakeBaseResponseCall {
+ val retrofitCall = mockk>>(relaxed = true)
+ it.onResponse(
+ retrofitCall,
+ Response.success(BaseResponse(isSuccess = true, code = 0, message = "ok", result = null))
+ )
+ }
+ val apiResultCall = ApiResultCall(origin, Unit::class.java as Type)
+
+ then("Success(Unit)을 반환한다") {
+ apiResultCall.enqueueAndGet() shouldBe ApiResult.Success(Unit)
+ }
+ }
+
+ `when`("API 성공인데 result가 null이고 Unit이 아니면") {
+ val origin = FakeBaseResponseCall {
+ val retrofitCall = mockk>>(relaxed = true)
+ it.onResponse(
+ retrofitCall,
+ Response.success(BaseResponse(isSuccess = true, code = 0, message = "ok", result = null))
+ )
+ }
+ val apiResultCall = ApiResultCall(origin, String::class.java)
+
+ then("UnknownError를 반환한다") {
+ val result = apiResultCall.enqueueAndGet()
+ (result is ApiResult.UnknownError) shouldBe true
+ }
+ }
+
+ `when`("API 성공 + result 존재") {
+ val origin = FakeBaseResponseCall {
+ val retrofitCall = mockk>>(relaxed = true)
+ it.onResponse(
+ retrofitCall,
+ Response.success(BaseResponse(isSuccess = true, code = 0, message = "ok", result = "payload"))
+ )
+ }
+ val apiResultCall = ApiResultCall(origin, String::class.java)
+
+ then("Success(result)를 반환한다") {
+ apiResultCall.enqueueAndGet() shouldBe ApiResult.Success("payload")
+ }
+ }
+
+ `when`("enqueue onFailure에서 IOException이 발생하면") {
+ val io = IOException("offline")
+ val origin = FakeBaseResponseCall {
+ val retrofitCall = mockk>>(relaxed = true)
+ it.onFailure(retrofitCall, io)
+ }
+ val apiResultCall = ApiResultCall(origin, String::class.java)
+
+ then("NetworkError를 반환하고 NetworkErrorEventBus를 발행한다") {
+ NetworkErrorEventBus.networkError.test {
+ val result = apiResultCall.enqueueAndGet() as ApiResult.NetworkError
+ result.exception shouldBe io
+ awaitItem() shouldBe Unit
+ cancelAndIgnoreRemainingEvents()
+ }
+ }
+ }
+
+ `when`("enqueue onFailure에서 기타 예외가 발생하면") {
+ val error = IllegalStateException("boom")
+ val origin = FakeBaseResponseCall {
+ val retrofitCall = mockk>>(relaxed = true)
+ it.onFailure(retrofitCall, error)
+ }
+ val apiResultCall = ApiResultCall(origin, String::class.java)
+
+ then("UnknownError를 반환한다") {
+ val result = apiResultCall.enqueueAndGet() as ApiResult.UnknownError
+ result.exception shouldBe error
+ }
+ }
+ }
+})
diff --git a/app/src/test/java/com/eatssu/android/di/network/TokenAuthenticatorBehaviorSpec.kt b/app/src/test/java/com/eatssu/android/di/network/TokenAuthenticatorBehaviorSpec.kt
new file mode 100644
index 000000000..e88a04466
--- /dev/null
+++ b/app/src/test/java/com/eatssu/android/di/network/TokenAuthenticatorBehaviorSpec.kt
@@ -0,0 +1,138 @@
+package com.eatssu.android.di.network
+
+import app.cash.turbine.test
+import com.eatssu.android.domain.usecase.auth.GetAccessTokenUseCase
+import com.eatssu.android.domain.usecase.auth.LogoutUseCase
+import com.eatssu.android.domain.usecase.auth.ReissueAndStoreResult
+import com.eatssu.android.domain.usecase.auth.ReissueAndStoreTokenUseCase
+import com.eatssu.android.presentation.base.LogoutReason
+import com.eatssu.android.presentation.base.TokenEventBus
+import com.eatssu.android.test.AppBehaviorSpec
+import io.kotest.matchers.shouldBe
+import io.mockk.coEvery
+import io.mockk.coVerify
+import io.mockk.every
+import io.mockk.mockk
+import okhttp3.Protocol
+import okhttp3.Request
+import okhttp3.Response
+import okhttp3.ResponseBody.Companion.toResponseBody
+
+class TokenAuthenticatorBehaviorSpec : AppBehaviorSpec({
+
+ given("TokenAuthenticator") {
+ val getAccessTokenUseCase = mockk()
+ val reissueAndStoreTokenUseCase = mockk()
+ val logoutUseCase = mockk()
+ val authenticator = TokenAuthenticator(
+ getAccessTokenUseCase = getAccessTokenUseCase,
+ reissueAndStoreTokenUseCase = reissueAndStoreTokenUseCase,
+ logoutUseCase = logoutUseCase,
+ )
+
+ fun buildResponse(
+ authHeader: String?,
+ prior: Response? = null,
+ withBody: Boolean = true,
+ ): Response {
+ val request = Request.Builder()
+ .url("https://example.com")
+ .apply {
+ if (authHeader != null) header("Authorization", authHeader)
+ }
+ .build()
+
+ return Response.Builder()
+ .request(request)
+ .protocol(Protocol.HTTP_1_1)
+ .code(401)
+ .message("Unauthorized")
+ .apply {
+ if (withBody) body("{}".toResponseBody())
+ }
+ .apply {
+ if (prior != null) priorResponse(prior)
+ }
+ .build()
+ }
+
+ coEvery { logoutUseCase() } returns Unit
+
+ `when`("이미 2회 이상 재시도한 응답이면") {
+ val prior = buildResponse("Bearer old", withBody = false)
+ val response = buildResponse("Bearer old", prior = prior)
+
+ then("재시도하지 않고 null을 반환한다") {
+ authenticator.authenticate(null, response) shouldBe null
+ }
+ }
+
+ `when`("다른 요청이 이미 토큰을 갱신한 경우") {
+ every { getAccessTokenUseCase() } returns "new-token"
+ val response = buildResponse("Bearer old-token")
+
+ then("저장된 토큰으로 요청 헤더를 교체해 반환한다") {
+ val retried = authenticator.authenticate(null, response)
+
+ retried?.header("Authorization") shouldBe "Bearer new-token"
+ coVerify(exactly = 0) { reissueAndStoreTokenUseCase() }
+ }
+ }
+
+ `when`("재발급이 성공하면") {
+ every { getAccessTokenUseCase() } returns "current-token"
+ coEvery { reissueAndStoreTokenUseCase() } returns ReissueAndStoreResult.Success("fresh-token")
+ val response = buildResponse("Bearer current-token")
+
+ then("새 토큰으로 요청을 재구성한다") {
+ val retried = authenticator.authenticate(null, response)
+ retried?.header("Authorization") shouldBe "Bearer fresh-token"
+ }
+ }
+
+ `when`("refresh token이 없어 재발급할 수 없으면") {
+ every { getAccessTokenUseCase() } returns "current-token"
+ coEvery { reissueAndStoreTokenUseCase() } returns ReissueAndStoreResult.MissingRefreshToken
+ val response = buildResponse("Bearer current-token")
+
+ then("로그아웃 후 MISSING_REFRESH_TOKEN 이벤트를 발행하고 null을 반환한다") {
+ TokenEventBus.tokenExpired.test {
+ authenticator.authenticate(null, response) shouldBe null
+ awaitItem() shouldBe LogoutReason.MISSING_REFRESH_TOKEN
+ coVerify { logoutUseCase() }
+ cancelAndIgnoreRemainingEvents()
+ }
+ }
+ }
+
+ `when`("refresh token이 만료된 경우") {
+ every { getAccessTokenUseCase() } returns "current-token"
+ coEvery {
+ reissueAndStoreTokenUseCase()
+ } returns ReissueAndStoreResult.RefreshInvalid(401, "expired")
+ val response = buildResponse("Bearer current-token")
+
+ then("로그아웃 후 REFRESH_TOKEN_EXPIRED 이벤트를 발행하고 null을 반환한다") {
+ TokenEventBus.tokenExpired.test {
+ authenticator.authenticate(null, response) shouldBe null
+ awaitItem() shouldBe LogoutReason.REFRESH_TOKEN_EXPIRED
+ coVerify { logoutUseCase() }
+ cancelAndIgnoreRemainingEvents()
+ }
+ }
+ }
+
+ `when`("재발급이 일시적 실패면") {
+ every { getAccessTokenUseCase() } returns "current-token"
+ coEvery {
+ reissueAndStoreTokenUseCase()
+ } returns ReissueAndStoreResult.TransientFailure(message = "timeout")
+ val response = buildResponse("Bearer current-token")
+
+ then("로그아웃 없이 null을 반환한다") {
+ authenticator.authenticate(null, response) shouldBe null
+ coVerify(exactly = 0) { logoutUseCase() }
+ }
+ }
+ }
+})
diff --git a/app/src/test/java/com/eatssu/android/di/network/TokenInterceptorBehaviorSpec.kt b/app/src/test/java/com/eatssu/android/di/network/TokenInterceptorBehaviorSpec.kt
new file mode 100644
index 000000000..d6eedd431
--- /dev/null
+++ b/app/src/test/java/com/eatssu/android/di/network/TokenInterceptorBehaviorSpec.kt
@@ -0,0 +1,62 @@
+package com.eatssu.android.di.network
+
+import com.eatssu.android.domain.usecase.auth.GetAccessTokenUseCase
+import com.eatssu.android.test.AppBehaviorSpec
+import io.kotest.matchers.shouldBe
+import io.mockk.every
+import io.mockk.mockk
+import io.mockk.slot
+import okhttp3.Interceptor
+import okhttp3.Protocol
+import okhttp3.Request
+import okhttp3.Response
+import okhttp3.ResponseBody.Companion.toResponseBody
+
+class TokenInterceptorBehaviorSpec : AppBehaviorSpec({
+
+ given("TokenInterceptor") {
+ val getAccessTokenUseCase = mockk()
+ val interceptor = TokenInterceptor(getAccessTokenUseCase)
+
+ fun buildResponse(request: Request): Response =
+ Response.Builder()
+ .request(request)
+ .protocol(Protocol.HTTP_1_1)
+ .code(200)
+ .message("OK")
+ .body("{}".toResponseBody())
+ .build()
+
+ `when`("access token이 존재하면") {
+ every { getAccessTokenUseCase() } returns "access-token"
+ val original = Request.Builder().url("https://example.com").build()
+ val chain = mockk()
+ val captured = slot()
+ every { chain.request() } returns original
+ every { chain.proceed(capture(captured)) } answers { buildResponse(captured.captured) }
+
+ then("Content-Type과 Authorization 헤더를 추가한다") {
+ interceptor.intercept(chain)
+
+ captured.captured.header("Content-Type") shouldBe "application/json"
+ captured.captured.header("Authorization") shouldBe "Bearer access-token"
+ }
+ }
+
+ `when`("access token이 비어있으면") {
+ every { getAccessTokenUseCase() } returns " "
+ val original = Request.Builder().url("https://example.com").build()
+ val chain = mockk()
+ val captured = slot()
+ every { chain.request() } returns original
+ every { chain.proceed(capture(captured)) } answers { buildResponse(captured.captured) }
+
+ then("Content-Type만 추가하고 Authorization은 생략한다") {
+ interceptor.intercept(chain)
+
+ captured.captured.header("Content-Type") shouldBe "application/json"
+ captured.captured.header("Authorization") shouldBe null
+ }
+ }
+ }
+})
diff --git a/app/src/test/java/com/eatssu/android/domain/usecase/alarm/AlarmNotificationStatusUseCasesBehaviorSpec.kt b/app/src/test/java/com/eatssu/android/domain/usecase/alarm/AlarmNotificationStatusUseCasesBehaviorSpec.kt
new file mode 100644
index 000000000..bcfae0cf9
--- /dev/null
+++ b/app/src/test/java/com/eatssu/android/domain/usecase/alarm/AlarmNotificationStatusUseCasesBehaviorSpec.kt
@@ -0,0 +1,60 @@
+package com.eatssu.android.domain.usecase.alarm
+
+import com.eatssu.android.data.local.SettingDataStore
+import com.eatssu.android.test.AppBehaviorSpec
+import io.kotest.matchers.shouldBe
+import io.mockk.coJustRun
+import io.mockk.coVerify
+import io.mockk.every
+import io.mockk.mockk
+import kotlinx.coroutines.ExperimentalCoroutinesApi
+import kotlinx.coroutines.flow.flowOf
+import kotlinx.coroutines.test.runTest
+
+@OptIn(ExperimentalCoroutinesApi::class)
+class AlarmNotificationStatusUseCasesBehaviorSpec : AppBehaviorSpec({
+
+ given("GetDailyNotificationStatusUseCase") {
+ val settingDataStore = mockk()
+ val useCase = GetDailyNotificationStatusUseCase(settingDataStore)
+
+ `when`("저장된 상태가 true면") {
+ every { settingDataStore.dailyNotificationStatus } returns flowOf(true)
+
+ then("true를 emit하는 flow를 반환한다") {
+ runTest {
+ useCase().collect { status ->
+ status shouldBe true
+ }
+ }
+ }
+ }
+
+ `when`("저장된 상태가 false면") {
+ every { settingDataStore.dailyNotificationStatus } returns flowOf(false)
+
+ then("false를 emit하는 flow를 반환한다") {
+ runTest {
+ useCase().collect { status ->
+ status shouldBe false
+ }
+ }
+ }
+ }
+ }
+
+ given("SetDailyNotificationStatusUseCase") {
+ val settingDataStore = mockk()
+ val useCase = SetDailyNotificationStatusUseCase(settingDataStore)
+ coJustRun { settingDataStore.setDailyNotificationStatus(any()) }
+
+ `when`("상태를 전달하면") {
+ then("SettingDataStore에 그대로 위임한다") {
+ runTest {
+ useCase(true)
+ coVerify(exactly = 1) { settingDataStore.setDailyNotificationStatus(true) }
+ }
+ }
+ }
+ }
+})
diff --git a/app/src/test/java/com/eatssu/android/domain/usecase/alarm/AlarmUseCaseBehaviorSpec.kt b/app/src/test/java/com/eatssu/android/domain/usecase/alarm/AlarmUseCaseBehaviorSpec.kt
new file mode 100644
index 000000000..fa1c08d47
--- /dev/null
+++ b/app/src/test/java/com/eatssu/android/domain/usecase/alarm/AlarmUseCaseBehaviorSpec.kt
@@ -0,0 +1,110 @@
+package com.eatssu.android.domain.usecase.alarm
+
+import android.app.AlarmManager
+import android.app.PendingIntent
+import android.content.Context
+import com.eatssu.android.test.AppBehaviorSpec
+import io.kotest.matchers.shouldBe
+import io.mockk.every
+import io.mockk.mockk
+import io.mockk.mockkStatic
+import io.mockk.slot
+import io.mockk.verify
+import java.time.Clock
+import java.time.ZoneId
+import java.time.ZonedDateTime
+
+class AlarmUseCaseBehaviorSpec : AppBehaviorSpec({
+
+ given("AlarmUseCase") {
+ val context = mockk()
+ val alarmManager = mockk(relaxed = true)
+ val pendingIntent = mockk()
+ val flags = PendingIntent.FLAG_IMMUTABLE or PendingIntent.FLAG_UPDATE_CURRENT
+
+ every { context.getSystemService(Context.ALARM_SERVICE) } returns alarmManager
+ mockkStatic(PendingIntent::class)
+ every { PendingIntent.getBroadcast(context, 0, any(), flags) } returns pendingIntent
+
+ `when`("현재 시간이 11시 이전이면") {
+ val zone = ZoneId.systemDefault()
+ val now = ZonedDateTime.of(2025, 1, 1, 10, 30, 0, 0, zone)
+ val clock = Clock.fixed(now.toInstant(), zone)
+ val useCase = AlarmUseCase(context, clock)
+ val triggerAtSlot = slot()
+
+ then("당일 11시로 repeating 알람을 등록한다") {
+ useCase.scheduleAlarm()
+
+ verify(exactly = 1) {
+ alarmManager.setRepeating(
+ AlarmManager.RTC_WAKEUP,
+ capture(triggerAtSlot),
+ AlarmManager.INTERVAL_DAY,
+ pendingIntent,
+ )
+ }
+
+ val expected = now
+ .withHour(11)
+ .withMinute(0)
+ .withSecond(0)
+ .withNano(0)
+ .toInstant()
+ .toEpochMilli()
+
+ triggerAtSlot.captured shouldBe expected
+ }
+ }
+
+ `when`("현재 시간이 11시 이상이면") {
+ val zone = ZoneId.systemDefault()
+ val now = ZonedDateTime.of(2025, 1, 1, 11, 0, 0, 0, zone)
+ val clock = Clock.fixed(now.toInstant(), zone)
+ val useCase = AlarmUseCase(context, clock)
+ val triggerAtSlot = slot()
+
+ then("다음날 11시로 repeating 알람을 등록한다") {
+ useCase.scheduleAlarm()
+
+ verify(exactly = 1) {
+ alarmManager.setRepeating(
+ AlarmManager.RTC_WAKEUP,
+ capture(triggerAtSlot),
+ AlarmManager.INTERVAL_DAY,
+ pendingIntent,
+ )
+ }
+
+ val expected = now
+ .plusDays(1)
+ .withHour(11)
+ .withMinute(0)
+ .withSecond(0)
+ .withNano(0)
+ .toInstant()
+ .toEpochMilli()
+
+ triggerAtSlot.captured shouldBe expected
+ }
+ }
+
+ `when`("cancelAlarm을 호출하면") {
+ val zone = ZoneId.systemDefault()
+ val clock = Clock.fixed(
+ ZonedDateTime.of(2025, 1, 1, 10, 0, 0, 0, zone).toInstant(),
+ zone,
+ )
+ val useCase = AlarmUseCase(context, clock)
+
+ then("등록했던 pendingIntent로 알람을 취소한다") {
+ useCase.cancelAlarm()
+ verify(exactly = 1) { alarmManager.cancel(pendingIntent) }
+ }
+ }
+
+ then("PendingIntent 생성 플래그는 고정 값을 사용한다") {
+ flags shouldBe (PendingIntent.FLAG_IMMUTABLE or PendingIntent.FLAG_UPDATE_CURRENT)
+ }
+ }
+})
diff --git a/app/src/test/java/com/eatssu/android/domain/usecase/auth/AuthDelegatingUseCasesBehaviorSpec.kt b/app/src/test/java/com/eatssu/android/domain/usecase/auth/AuthDelegatingUseCasesBehaviorSpec.kt
new file mode 100644
index 000000000..89def3b02
--- /dev/null
+++ b/app/src/test/java/com/eatssu/android/domain/usecase/auth/AuthDelegatingUseCasesBehaviorSpec.kt
@@ -0,0 +1,200 @@
+package com.eatssu.android.domain.usecase.auth
+
+import com.eatssu.android.data.local.AccountDataStore
+import com.eatssu.android.data.local.SettingDataStore
+import com.eatssu.android.data.local.TokenStore
+import com.eatssu.android.data.remote.dto.request.CheckValidTokenRequest
+import com.eatssu.android.domain.model.ReissueTokenResult
+import com.eatssu.android.domain.repository.OauthRepository
+import com.eatssu.android.domain.repository.UserRepository
+import com.eatssu.android.test.AppBehaviorSpec
+import com.eatssu.android.test.sampleToken
+import com.eatssu.common.enums.DeviceType
+import io.kotest.matchers.shouldBe
+import io.mockk.Runs
+import io.mockk.coEvery
+import io.mockk.coJustRun
+import io.mockk.coVerify
+import io.mockk.coVerifyOrder
+import io.mockk.every
+import io.mockk.just
+import io.mockk.mockk
+import io.mockk.slot
+import kotlinx.coroutines.ExperimentalCoroutinesApi
+import kotlinx.coroutines.test.runTest
+
+@OptIn(ExperimentalCoroutinesApi::class)
+class AuthDelegatingUseCasesBehaviorSpec : AppBehaviorSpec({
+
+ given("GetAccessTokenUseCase") {
+ val tokenStore = mockk()
+ every { tokenStore.accessToken } returns "access-token"
+
+ `when`("invoke를 호출하면") {
+ val useCase = GetAccessTokenUseCase(tokenStore)
+
+ then("TokenStore.accessToken 값을 반환한다") {
+ useCase() shouldBe "access-token"
+ }
+ }
+ }
+
+ given("GetRefreshTokenUseCase") {
+ val tokenStore = mockk()
+ every { tokenStore.refreshToken } returns "refresh-token"
+
+ `when`("invoke를 호출하면") {
+ val useCase = GetRefreshTokenUseCase(tokenStore)
+
+ then("TokenStore.refreshToken 값을 반환한다") {
+ useCase() shouldBe "refresh-token"
+ }
+ }
+ }
+
+ given("SetAccessTokenUseCase") {
+ val tokenStore = mockk()
+ every { tokenStore.accessToken = "new-access" } just Runs
+ val useCase = SetAccessTokenUseCase(tokenStore)
+
+ `when`("토큰을 전달하면") {
+ then("TokenStore.accessToken setter를 호출한다") {
+ useCase("new-access")
+ io.mockk.verify(exactly = 1) { tokenStore.accessToken = "new-access" }
+ }
+ }
+ }
+
+ given("SetRefreshTokenUseCase") {
+ val tokenStore = mockk()
+ every { tokenStore.refreshToken = "new-refresh" } just Runs
+ val useCase = SetRefreshTokenUseCase(tokenStore)
+
+ `when`("토큰을 전달하면") {
+ then("TokenStore.refreshToken setter를 호출한다") {
+ useCase("new-refresh")
+ io.mockk.verify(exactly = 1) { tokenStore.refreshToken = "new-refresh" }
+ }
+ }
+ }
+
+ given("LoginUseCase") {
+ val oauthRepository = mockk()
+ val useCase = LoginUseCase(oauthRepository)
+
+ `when`("repository가 token을 반환하면") {
+ val token = sampleToken("a", "r")
+ coEvery { oauthRepository.login("a@b.com", "provider-id", DeviceType.ANDROID) } returns token
+
+ then("동일 token을 반환한다") {
+ runTest {
+ useCase("a@b.com", "provider-id", DeviceType.ANDROID) shouldBe token
+ }
+ }
+ }
+
+ `when`("repository가 null을 반환하면") {
+ coEvery { oauthRepository.login(any(), any(), any()) } returns null
+
+ then("null을 반환한다") {
+ runTest {
+ useCase("a@b.com", "provider-id", DeviceType.ANDROID) shouldBe null
+ }
+ }
+ }
+ }
+
+ given("LogoutUseCase") {
+ val accountDataStore = mockk()
+ val tokenStore = mockk()
+ val settingDataStore = mockk()
+ val useCase = LogoutUseCase(accountDataStore, tokenStore, settingDataStore)
+
+ coJustRun { accountDataStore.clear() }
+ every { tokenStore.clear() } just Runs
+ coJustRun { settingDataStore.clear() }
+
+ `when`("invoke를 호출하면") {
+ then("로컬 저장소를 순서대로 clear한다") {
+ runTest {
+ useCase()
+ coVerifyOrder {
+ accountDataStore.clear()
+ tokenStore.clear()
+ settingDataStore.clear()
+ }
+ }
+ }
+ }
+ }
+
+ given("ReissueTokenUseCase") {
+ val oauthRepository = mockk()
+ val useCase = ReissueTokenUseCase(oauthRepository)
+
+ `when`("repository가 성공 결과를 주면") {
+ val result = ReissueTokenResult.Success(sampleToken("newA", "newR"))
+ coEvery { oauthRepository.reissueToken("refresh-token") } returns result
+
+ then("동일 결과를 반환한다") {
+ runTest {
+ useCase("refresh-token") shouldBe result
+ }
+ }
+ }
+
+ `when`("repository가 실패 결과를 주면") {
+ val result = ReissueTokenResult.Failure(responseCode = 500, message = "error")
+ coEvery { oauthRepository.reissueToken("refresh-token") } returns result
+
+ then("동일 실패 결과를 반환한다") {
+ runTest {
+ useCase("refresh-token") shouldBe result
+ }
+ }
+ }
+ }
+
+ given("GetIsAccessTokenValidUseCase") {
+ val oauthRepository = mockk()
+ val useCase = GetIsAccessTokenValidUseCase(oauthRepository)
+
+ `when`("토큰 유효성 검사를 요청하면") {
+ val bodySlot = slot()
+ coEvery { oauthRepository.checkValidToken(capture(bodySlot)) } returns true
+
+ then("CheckValidTokenRequest(token)으로 위임하고 결과를 반환한다") {
+ runTest {
+ useCase("user-access-token") shouldBe true
+ bodySlot.captured.token shouldBe "user-access-token"
+ }
+ }
+ }
+ }
+
+ given("SignOutUseCase") {
+ val userRepository = mockk()
+ val useCase = SignOutUseCase(userRepository)
+
+ `when`("repository가 true를 반환하면") {
+ coEvery { userRepository.signOut() } returns true
+
+ then("true를 반환한다") {
+ runTest {
+ useCase() shouldBe true
+ coVerify(exactly = 1) { userRepository.signOut() }
+ }
+ }
+ }
+
+ `when`("repository가 false를 반환하면") {
+ coEvery { userRepository.signOut() } returns false
+
+ then("false를 반환한다") {
+ runTest {
+ useCase() shouldBe false
+ }
+ }
+ }
+ }
+})
diff --git a/app/src/test/java/com/eatssu/android/domain/usecase/auth/ReissueAndStoreTokenUseCaseBehaviorSpec.kt b/app/src/test/java/com/eatssu/android/domain/usecase/auth/ReissueAndStoreTokenUseCaseBehaviorSpec.kt
new file mode 100644
index 000000000..4267609d0
--- /dev/null
+++ b/app/src/test/java/com/eatssu/android/domain/usecase/auth/ReissueAndStoreTokenUseCaseBehaviorSpec.kt
@@ -0,0 +1,114 @@
+package com.eatssu.android.domain.usecase.auth
+
+import com.eatssu.android.domain.model.ReissueTokenResult
+import com.eatssu.android.test.AppBehaviorSpec
+import com.eatssu.android.test.sampleToken
+import io.kotest.matchers.shouldBe
+import io.mockk.Runs
+import io.mockk.coEvery
+import io.mockk.coVerify
+import io.mockk.every
+import io.mockk.just
+import io.mockk.mockk
+import io.mockk.verify
+import kotlinx.coroutines.ExperimentalCoroutinesApi
+import kotlinx.coroutines.test.runTest
+
+@OptIn(ExperimentalCoroutinesApi::class)
+class ReissueAndStoreTokenUseCaseBehaviorSpec : AppBehaviorSpec({
+
+ given("토큰 재발급 및 저장") {
+ val getRefreshTokenUseCase = mockk()
+ val reissueTokenUseCase = mockk()
+ val setAccessTokenUseCase = mockk()
+ val setRefreshTokenUseCase = mockk()
+
+ every { setAccessTokenUseCase(any()) } just Runs
+ every { setRefreshTokenUseCase(any()) } just Runs
+
+ val useCase = ReissueAndStoreTokenUseCase(
+ getRefreshTokenUseCase = getRefreshTokenUseCase,
+ reissueTokenUseCase = reissueTokenUseCase,
+ setAccessTokenUseCase = setAccessTokenUseCase,
+ setRefreshTokenUseCase = setRefreshTokenUseCase,
+ )
+
+ `when`("refresh token이 비어있으면") {
+ every { getRefreshTokenUseCase() } returns " "
+
+ then("MissingRefreshToken을 반환한다") {
+ runTest {
+ useCase() shouldBe ReissueAndStoreResult.MissingRefreshToken
+ coVerify(exactly = 0) { reissueTokenUseCase(any()) }
+ }
+ }
+ }
+
+ `when`("재발급이 성공하고 토큰이 유효하면") {
+ every { getRefreshTokenUseCase() } returns "refresh"
+ coEvery { reissueTokenUseCase("refresh") } returns ReissueTokenResult.Success(
+ sampleToken(access = "new-access", refresh = "new-refresh")
+ )
+
+ then("새 access token을 저장하고 Success를 반환한다") {
+ runTest {
+ useCase() shouldBe ReissueAndStoreResult.Success(accessToken = "new-access")
+ verify { setAccessTokenUseCase("new-access") }
+ verify { setRefreshTokenUseCase("new-refresh") }
+ }
+ }
+ }
+
+ `when`("재발급 성공이지만 빈 토큰이 반환되면") {
+ every { getRefreshTokenUseCase() } returns "refresh"
+ coEvery { reissueTokenUseCase("refresh") } returns ReissueTokenResult.Success(
+ sampleToken(access = "", refresh = "new-refresh")
+ )
+
+ then("TransientFailure를 반환하고 저장하지 않는다") {
+ runTest {
+ useCase() shouldBe ReissueAndStoreResult.TransientFailure(message = "reissue returned blank tokens")
+ verify(exactly = 0) { setAccessTokenUseCase(any()) }
+ verify(exactly = 0) { setRefreshTokenUseCase(any()) }
+ }
+ }
+ }
+
+ `when`("재발급이 401/403으로 실패하면") {
+ every { getRefreshTokenUseCase() } returns "refresh"
+ coEvery { reissueTokenUseCase("refresh") } returns ReissueTokenResult.Failure(
+ responseCode = 401,
+ message = "invalid refresh",
+ )
+
+ then("RefreshInvalid를 반환한다") {
+ runTest {
+ useCase() shouldBe ReissueAndStoreResult.RefreshInvalid(
+ responseCode = 401,
+ message = "invalid refresh",
+ )
+ }
+ }
+ }
+
+ `when`("재발급이 일시적 오류로 실패하면") {
+ val throwable = IllegalStateException("boom")
+ every { getRefreshTokenUseCase() } returns "refresh"
+ coEvery { reissueTokenUseCase("refresh") } returns ReissueTokenResult.Failure(
+ responseCode = 500,
+ message = "server error",
+ throwable = throwable,
+ )
+
+ then("TransientFailure를 반환한다") {
+ runTest {
+ useCase() shouldBe ReissueAndStoreResult.TransientFailure(
+ responseCode = 500,
+ message = "server error",
+ throwable = throwable,
+ )
+ }
+ }
+ }
+ }
+})
diff --git a/app/src/test/java/com/eatssu/android/domain/usecase/health/HealthCheckUseCaseBehaviorSpec.kt b/app/src/test/java/com/eatssu/android/domain/usecase/health/HealthCheckUseCaseBehaviorSpec.kt
new file mode 100644
index 000000000..019862136
--- /dev/null
+++ b/app/src/test/java/com/eatssu/android/domain/usecase/health/HealthCheckUseCaseBehaviorSpec.kt
@@ -0,0 +1,40 @@
+package com.eatssu.android.domain.usecase.health
+
+import com.eatssu.android.domain.repository.HealthCheckRepository
+import com.eatssu.android.test.AppBehaviorSpec
+import io.kotest.matchers.shouldBe
+import io.mockk.coEvery
+import io.mockk.coVerify
+import io.mockk.mockk
+import kotlinx.coroutines.ExperimentalCoroutinesApi
+import kotlinx.coroutines.test.runTest
+
+@OptIn(ExperimentalCoroutinesApi::class)
+class HealthCheckUseCaseBehaviorSpec : AppBehaviorSpec({
+
+ given("HealthCheckUseCase") {
+ val repository = mockk()
+ val useCase = HealthCheckUseCase(repository)
+
+ `when`("헬스체크가 성공하면") {
+ coEvery { repository.checkHealth() } returns true
+
+ then("true를 반환한다") {
+ runTest {
+ useCase() shouldBe true
+ coVerify(exactly = 1) { repository.checkHealth() }
+ }
+ }
+ }
+
+ `when`("헬스체크가 실패하면") {
+ coEvery { repository.checkHealth() } returns false
+
+ then("false를 반환한다") {
+ runTest {
+ useCase() shouldBe false
+ }
+ }
+ }
+ }
+})
diff --git a/app/src/test/java/com/eatssu/android/domain/usecase/menu/GetMenuListUseCaseBehaviorSpec.kt b/app/src/test/java/com/eatssu/android/domain/usecase/menu/GetMenuListUseCaseBehaviorSpec.kt
new file mode 100644
index 000000000..bf317484e
--- /dev/null
+++ b/app/src/test/java/com/eatssu/android/domain/usecase/menu/GetMenuListUseCaseBehaviorSpec.kt
@@ -0,0 +1,54 @@
+package com.eatssu.android.domain.usecase.menu
+
+import com.eatssu.android.domain.model.Menu
+import com.eatssu.android.domain.repository.MealRepository
+import com.eatssu.android.domain.repository.MenuRepository
+import com.eatssu.android.test.AppBehaviorSpec
+import com.eatssu.common.enums.Restaurant
+import com.eatssu.common.enums.Time
+import io.kotest.matchers.shouldBe
+import io.mockk.coEvery
+import io.mockk.coVerify
+import io.mockk.mockk
+import kotlinx.coroutines.ExperimentalCoroutinesApi
+import kotlinx.coroutines.test.runTest
+
+@OptIn(ExperimentalCoroutinesApi::class)
+class GetMenuListUseCaseBehaviorSpec : AppBehaviorSpec({
+
+ given("메뉴 목록 조회") {
+ val menuRepository = mockk()
+ val mealRepository = mockk()
+ val useCase = GetMenuListUseCase(menuRepository, mealRepository)
+
+ `when`("고정식당 메뉴를 조회하면") {
+ val result = listOf(Menu(id = 1, name = "돈까스", price = 5000, rate = 4.0))
+ coEvery { menuRepository.getFixedMenuList(Restaurant.FOOD_COURT) } returns result
+
+ then("menuRepository.getFixedMenuList를 사용한다") {
+ runTest {
+ useCase(Restaurant.FOOD_COURT, "2025-01-01", Time.LUNCH) shouldBe result
+ coVerify(exactly = 1) { menuRepository.getFixedMenuList(Restaurant.FOOD_COURT) }
+ coVerify(exactly = 0) { mealRepository.getTodayMenuList(any(), any(), any()) }
+ }
+ }
+ }
+
+ `when`("변동식당 메뉴를 조회하면") {
+ val result = listOf(Menu(id = 2, name = "비빔밥", price = 4500, rate = 3.5))
+ coEvery {
+ mealRepository.getTodayMenuList("2025-01-01", Restaurant.HAKSIK, Time.DINNER)
+ } returns result
+
+ then("mealRepository.getTodayMenuList를 사용한다") {
+ runTest {
+ useCase(Restaurant.HAKSIK, "2025-01-01", Time.DINNER) shouldBe result
+ coVerify(exactly = 1) {
+ mealRepository.getTodayMenuList("2025-01-01", Restaurant.HAKSIK, Time.DINNER)
+ }
+ coVerify(exactly = 0) { menuRepository.getFixedMenuList(any()) }
+ }
+ }
+ }
+ }
+})
diff --git a/app/src/test/java/com/eatssu/android/domain/usecase/menu/GetValidMenusOfMealUseCaseBehaviorSpec.kt b/app/src/test/java/com/eatssu/android/domain/usecase/menu/GetValidMenusOfMealUseCaseBehaviorSpec.kt
new file mode 100644
index 000000000..82dabed08
--- /dev/null
+++ b/app/src/test/java/com/eatssu/android/domain/usecase/menu/GetValidMenusOfMealUseCaseBehaviorSpec.kt
@@ -0,0 +1,45 @@
+package com.eatssu.android.domain.usecase.menu
+
+import com.eatssu.android.domain.model.MenuMini
+import com.eatssu.android.domain.repository.ReviewRepository
+import com.eatssu.android.test.AppBehaviorSpec
+import io.kotest.matchers.shouldBe
+import io.mockk.coEvery
+import io.mockk.coVerify
+import io.mockk.mockk
+import kotlinx.coroutines.ExperimentalCoroutinesApi
+import kotlinx.coroutines.test.runTest
+
+@OptIn(ExperimentalCoroutinesApi::class)
+class GetValidMenusOfMealUseCaseBehaviorSpec : AppBehaviorSpec({
+
+ given("GetValidMenusOfMealUseCase") {
+ val reviewRepository = mockk()
+ val useCase = GetValidMenusOfMealUseCase(reviewRepository)
+ val menus = listOf(
+ MenuMini(id = 1L, name = "제육"),
+ MenuMini(id = 2L, name = "돈까스"),
+ )
+
+ `when`("유효 메뉴 목록 조회가 성공하면") {
+ coEvery { reviewRepository.getValidMenusByMealId(10L) } returns menus
+
+ then("동일 목록을 반환한다") {
+ runTest {
+ useCase(10L) shouldBe menus
+ coVerify(exactly = 1) { reviewRepository.getValidMenusByMealId(10L) }
+ }
+ }
+ }
+
+ `when`("유효 메뉴 목록이 비어있으면") {
+ coEvery { reviewRepository.getValidMenusByMealId(10L) } returns emptyList()
+
+ then("빈 리스트를 반환한다") {
+ runTest {
+ useCase(10L) shouldBe emptyList()
+ }
+ }
+ }
+ }
+})
diff --git a/app/src/test/java/com/eatssu/android/domain/usecase/review/GetReviewInfoUseCaseBehaviorSpec.kt b/app/src/test/java/com/eatssu/android/domain/usecase/review/GetReviewInfoUseCaseBehaviorSpec.kt
new file mode 100644
index 000000000..d5fcd1781
--- /dev/null
+++ b/app/src/test/java/com/eatssu/android/domain/usecase/review/GetReviewInfoUseCaseBehaviorSpec.kt
@@ -0,0 +1,57 @@
+package com.eatssu.android.domain.usecase.review
+
+import com.eatssu.android.test.AppBehaviorSpec
+import com.eatssu.android.test.sampleReviewInfo
+import com.eatssu.android.domain.repository.ReviewRepository
+import com.eatssu.common.enums.MenuType
+import io.kotest.matchers.shouldBe
+import io.mockk.coEvery
+import io.mockk.coVerify
+import io.mockk.mockk
+import kotlinx.coroutines.ExperimentalCoroutinesApi
+import kotlinx.coroutines.test.runTest
+
+@OptIn(ExperimentalCoroutinesApi::class)
+class GetReviewInfoUseCaseBehaviorSpec : AppBehaviorSpec({
+
+ given("리뷰 통계 조회") {
+ val reviewRepository = mockk()
+ val useCase = GetReviewInfoUseCase(reviewRepository)
+
+ `when`("고정 메뉴 리뷰 통계를 조회하면") {
+ val info = sampleReviewInfo(count = 10, rating = 4.2)
+ coEvery { reviewRepository.getMenuReviewInfo(1L) } returns info
+
+ then("menu 리뷰 정보를 반환한다") {
+ runTest {
+ useCase(MenuType.FIXED, 1L) shouldBe info
+ coVerify(exactly = 1) { reviewRepository.getMenuReviewInfo(1L) }
+ coVerify(exactly = 0) { reviewRepository.getMealReviewInfo(any()) }
+ }
+ }
+ }
+
+ `when`("변동 메뉴 리뷰 통계를 조회하면") {
+ val info = sampleReviewInfo(count = 5, rating = 3.8)
+ coEvery { reviewRepository.getMealReviewInfo(2L) } returns info
+
+ then("meal 리뷰 정보를 반환한다") {
+ runTest {
+ useCase(MenuType.VARIABLE, 2L) shouldBe info
+ coVerify(exactly = 1) { reviewRepository.getMealReviewInfo(2L) }
+ coVerify(exactly = 0) { reviewRepository.getMenuReviewInfo(any()) }
+ }
+ }
+ }
+
+ `when`("저장소가 null을 반환하면") {
+ coEvery { reviewRepository.getMealReviewInfo(3L) } returns null
+
+ then("null을 그대로 반환한다") {
+ runTest {
+ useCase(MenuType.VARIABLE, 3L) shouldBe null
+ }
+ }
+ }
+ }
+})
diff --git a/app/src/test/java/com/eatssu/android/domain/usecase/review/ReviewDelegatingUseCasesBehaviorSpec.kt b/app/src/test/java/com/eatssu/android/domain/usecase/review/ReviewDelegatingUseCasesBehaviorSpec.kt
new file mode 100644
index 000000000..7e5be983e
--- /dev/null
+++ b/app/src/test/java/com/eatssu/android/domain/usecase/review/ReviewDelegatingUseCasesBehaviorSpec.kt
@@ -0,0 +1,169 @@
+package com.eatssu.android.domain.usecase.review
+
+import androidx.paging.PagingData
+import com.eatssu.android.data.remote.dto.request.ReportRequest
+import com.eatssu.android.domain.model.Review
+import com.eatssu.android.domain.repository.ReportRepository
+import com.eatssu.android.domain.repository.ReviewRepository
+import com.eatssu.android.test.AppBehaviorSpec
+import com.eatssu.android.test.sampleReview
+import com.eatssu.common.enums.MenuType
+import io.kotest.matchers.shouldBe
+import io.mockk.coEvery
+import io.mockk.coVerify
+import io.mockk.every
+import io.mockk.mockk
+import kotlinx.coroutines.ExperimentalCoroutinesApi
+import kotlinx.coroutines.flow.flowOf
+import kotlinx.coroutines.test.runTest
+import java.io.File
+
+@OptIn(ExperimentalCoroutinesApi::class)
+class ReviewDelegatingUseCasesBehaviorSpec : AppBehaviorSpec({
+
+ given("DeleteReviewUseCase") {
+ val reviewRepository = mockk()
+ val useCase = DeleteReviewUseCase(reviewRepository)
+
+ `when`("삭제 요청이 성공하면") {
+ coEvery { reviewRepository.deleteReview(11L) } returns true
+
+ then("true를 반환한다") {
+ runTest {
+ useCase(11L) shouldBe true
+ coVerify(exactly = 1) { reviewRepository.deleteReview(11L) }
+ }
+ }
+ }
+
+ `when`("삭제 요청이 실패하면") {
+ coEvery { reviewRepository.deleteReview(11L) } returns false
+
+ then("false를 반환한다") {
+ runTest {
+ useCase(11L) shouldBe false
+ }
+ }
+ }
+ }
+
+ given("GetImageUrlUseCase") {
+ val reviewRepository = mockk()
+ val useCase = GetImageUrlUseCase(reviewRepository)
+ val file = File("image.jpg")
+
+ `when`("이미지 업로드 URL이 존재하면") {
+ coEvery { reviewRepository.getImageString(file) } returns "https://img"
+
+ then("URL을 그대로 반환한다") {
+ runTest {
+ useCase(file) shouldBe "https://img"
+ }
+ }
+ }
+
+ `when`("이미지 업로드가 실패하면") {
+ coEvery { reviewRepository.getImageString(file) } returns null
+
+ then("null을 반환한다") {
+ runTest {
+ useCase(file) shouldBe null
+ }
+ }
+ }
+ }
+
+ given("GetMyReviewsUseCase") {
+ val reviewRepository = mockk()
+ val useCase = GetMyReviewsUseCase(reviewRepository)
+ val reviews = listOf(sampleReview(id = 1L), sampleReview(id = 2L))
+
+ `when`("repository가 내 리뷰 목록을 반환하면") {
+ coEvery { reviewRepository.getMyReviews() } returns reviews
+
+ then("동일 목록을 반환한다") {
+ runTest {
+ useCase() shouldBe reviews
+ }
+ }
+ }
+ }
+
+ given("GetReviewListPagedUseCase") {
+ val reviewRepository = mockk()
+ val useCase = GetReviewListPagedUseCase(reviewRepository)
+ val menuFlow = flowOf(PagingData.empty())
+ val mealFlow = flowOf(PagingData.empty())
+
+ every { reviewRepository.getMenuReviewListPaged(10L) } returns menuFlow
+ every { reviewRepository.getMealReviewListPaged(20L) } returns mealFlow
+
+ `when`("menuType이 FIXED면") {
+ then("고정 메뉴 paging flow를 반환한다") {
+ useCase(MenuType.FIXED, 10L) shouldBe menuFlow
+ }
+ }
+
+ `when`("menuType이 VARIABLE면") {
+ then("변동 메뉴 paging flow를 반환한다") {
+ useCase(MenuType.VARIABLE, 20L) shouldBe mealFlow
+ }
+ }
+ }
+
+ given("ModifyReviewUseCase") {
+ val reviewRepository = mockk()
+ val useCase = ModifyReviewUseCase(reviewRepository)
+ val menuLikes = listOf(Review.MenuLikeInfo(1L, "A", true))
+
+ `when`("수정 요청이 성공하면") {
+ coEvery { reviewRepository.modifyReview(1L, 5, "content", menuLikes) } returns true
+
+ then("true를 반환한다") {
+ runTest {
+ useCase(1L, 5, "content", menuLikes) shouldBe true
+ }
+ }
+ }
+
+ `when`("수정 요청이 실패하면") {
+ coEvery { reviewRepository.modifyReview(1L, 5, "content", menuLikes) } returns false
+
+ then("false를 반환한다") {
+ runTest {
+ useCase(1L, 5, "content", menuLikes) shouldBe false
+ }
+ }
+ }
+ }
+
+ given("PostReportUseCase") {
+ val reportRepository = mockk()
+ val useCase = PostReportUseCase(reportRepository)
+ val body = ReportRequest(
+ reviewId = 3L,
+ reportType = "SPAM",
+ content = "신고 사유",
+ )
+
+ `when`("신고가 성공하면") {
+ coEvery { reportRepository.reportReview(body) } returns true
+
+ then("true를 반환한다") {
+ runTest {
+ useCase(body) shouldBe true
+ }
+ }
+ }
+
+ `when`("신고가 실패하면") {
+ coEvery { reportRepository.reportReview(body) } returns false
+
+ then("false를 반환한다") {
+ runTest {
+ useCase(body) shouldBe false
+ }
+ }
+ }
+ }
+})
diff --git a/app/src/test/java/com/eatssu/android/domain/usecase/review/WriteReviewUseCaseBehaviorSpec.kt b/app/src/test/java/com/eatssu/android/domain/usecase/review/WriteReviewUseCaseBehaviorSpec.kt
new file mode 100644
index 000000000..279f93992
--- /dev/null
+++ b/app/src/test/java/com/eatssu/android/domain/usecase/review/WriteReviewUseCaseBehaviorSpec.kt
@@ -0,0 +1,127 @@
+package com.eatssu.android.domain.usecase.review
+
+import com.eatssu.android.domain.repository.ReviewRepository
+import com.eatssu.android.test.AppBehaviorSpec
+import com.eatssu.common.enums.MenuType
+import io.kotest.matchers.shouldBe
+import io.mockk.coEvery
+import io.mockk.coVerify
+import io.mockk.mockk
+import kotlinx.coroutines.ExperimentalCoroutinesApi
+import kotlinx.coroutines.test.runTest
+
+@OptIn(ExperimentalCoroutinesApi::class)
+class WriteReviewUseCaseBehaviorSpec : AppBehaviorSpec({
+
+ given("리뷰 작성 유즈케이스") {
+ val reviewRepository = mockk()
+ val useCase = WriteReviewUseCase(reviewRepository)
+
+ `when`("FIXED 메뉴에 이미지 없이 작성하면") {
+ coEvery {
+ reviewRepository.writeMenuReview(
+ rating = 5,
+ content = "good",
+ imageUrls = emptyList(),
+ likeMenuIdList = listOf(1L),
+ )
+ } returns true
+
+ then("writeMenuReview를 호출하고 결과를 반환한다") {
+ runTest {
+ useCase(
+ menuType = MenuType.FIXED,
+ itemId = 100L,
+ rating = 5,
+ content = "good",
+ imageUrl = null,
+ likeMenuIdList = listOf(1L),
+ ) shouldBe true
+
+ coVerify(exactly = 1) {
+ reviewRepository.writeMenuReview(
+ rating = 5,
+ content = "good",
+ imageUrls = emptyList(),
+ likeMenuIdList = listOf(1L),
+ )
+ }
+ }
+ }
+ }
+
+ `when`("FIXED 메뉴에 이미지가 있으면") {
+ coEvery {
+ reviewRepository.writeMenuReview(
+ rating = 4,
+ content = "",
+ imageUrls = listOf("https://img"),
+ likeMenuIdList = null,
+ )
+ } returns false
+
+ then("이미지 URL을 리스트로 전달한다") {
+ runTest {
+ useCase(
+ menuType = MenuType.FIXED,
+ itemId = 100L,
+ rating = 4,
+ content = "",
+ imageUrl = "https://img",
+ likeMenuIdList = null,
+ ) shouldBe false
+ }
+ }
+ }
+
+ `when`("VARIABLE 메뉴에 이미지 없이 작성하면") {
+ coEvery {
+ reviewRepository.writeMealReview(
+ mealId = 77L,
+ rating = 3,
+ content = "ok",
+ imageUrls = emptyList(),
+ likeMenuIdList = emptyList(),
+ )
+ } returns true
+
+ then("writeMealReview를 호출하고 결과를 반환한다") {
+ runTest {
+ useCase(
+ menuType = MenuType.VARIABLE,
+ itemId = 77L,
+ rating = 3,
+ content = "ok",
+ imageUrl = null,
+ likeMenuIdList = emptyList(),
+ ) shouldBe true
+ }
+ }
+ }
+
+ `when`("VARIABLE 메뉴에 이미지가 있으면") {
+ coEvery {
+ reviewRepository.writeMealReview(
+ mealId = 88L,
+ rating = 2,
+ content = "bad",
+ imageUrls = listOf("https://img2"),
+ likeMenuIdList = listOf(9L),
+ )
+ } returns false
+
+ then("mealId와 이미지 리스트를 전달한다") {
+ runTest {
+ useCase(
+ menuType = MenuType.VARIABLE,
+ itemId = 88L,
+ rating = 2,
+ content = "bad",
+ imageUrl = "https://img2",
+ likeMenuIdList = listOf(9L),
+ ) shouldBe false
+ }
+ }
+ }
+ }
+})
diff --git a/app/src/test/java/com/eatssu/android/domain/usecase/user/GetPartnershipDetailUseCaseBehaviorSpec.kt b/app/src/test/java/com/eatssu/android/domain/usecase/user/GetPartnershipDetailUseCaseBehaviorSpec.kt
new file mode 100644
index 000000000..47d9cdfd0
--- /dev/null
+++ b/app/src/test/java/com/eatssu/android/domain/usecase/user/GetPartnershipDetailUseCaseBehaviorSpec.kt
@@ -0,0 +1,94 @@
+package com.eatssu.android.domain.usecase.user
+
+import com.eatssu.android.domain.model.Partnership
+import com.eatssu.android.domain.model.RestaurantType
+import com.eatssu.android.test.AppBehaviorSpec
+import io.kotest.matchers.shouldBe
+
+class GetPartnershipDetailUseCaseBehaviorSpec : AppBehaviorSpec({
+
+ given("가게 제휴 상세 조회") {
+ val useCase = GetPartnershipDetailUseCase()
+ val infos = listOf(
+ Partnership.PartnershipInfo(
+ id = 1,
+ partnershipType = "DISCOUNT",
+ collegeName = "IT",
+ departmentName = "CS",
+ likeCount = 2,
+ isLiked = true,
+ description = "10% 할인",
+ startDate = "2025-01-01",
+ endDate = "2025-12-31",
+ ),
+ Partnership.PartnershipInfo(
+ id = 2,
+ partnershipType = "GIFT",
+ collegeName = "IT",
+ departmentName = "CS",
+ likeCount = 1,
+ isLiked = false,
+ description = "음료 증정",
+ startDate = "2025-02-01",
+ endDate = "2025-11-30",
+ ),
+ )
+ val partnerships = listOf(
+ Partnership(
+ storeName = "Cafe A",
+ longitude = 127.0,
+ latitude = 37.0,
+ restaurantType = RestaurantType.CAFE,
+ partnershipInfos = infos,
+ )
+ )
+
+ `when`("storeName이 존재하지 않으면") {
+ then("null을 반환한다") {
+ useCase(partnerships, "Unknown", 1) shouldBe null
+ }
+ }
+
+ `when`("partnershipId가 주어지고 매칭되면") {
+ then("해당 id의 PartnershipRestaurant로 매핑한다") {
+ val result = useCase(partnerships, "Cafe A", 2)
+ result?.id shouldBe 2
+ result?.storeName shouldBe "Cafe A"
+ result?.description shouldBe "음료 증정"
+ result?.restaurantType shouldBe RestaurantType.CAFE
+ }
+ }
+
+ `when`("partnershipId가 없으면") {
+ then("첫 번째 제휴 정보를 대표로 반환한다") {
+ val result = useCase(partnerships, "Cafe A", null)
+ result?.id shouldBe 1
+ result?.description shouldBe "10% 할인"
+ }
+ }
+
+ `when`("partnershipId가 있지만 매칭이 없으면") {
+ then("첫 번째 제휴 정보로 fallback 한다") {
+ val result = useCase(partnerships, "Cafe A", 99)
+ result?.id shouldBe 1
+ result?.description shouldBe "10% 할인"
+ }
+ }
+
+ `when`("제휴 정보 리스트가 비어있으면") {
+ val emptyInfoPartnership = listOf(
+ Partnership(
+ storeName = "Cafe A",
+ longitude = 127.0,
+ latitude = 37.0,
+ restaurantType = RestaurantType.CAFE,
+ partnershipInfos = emptyList(),
+ )
+ )
+
+ then("null을 반환한다") {
+ useCase(emptyInfoPartnership, "Cafe A", null) shouldBe null
+ }
+ }
+ }
+})
diff --git a/app/src/test/java/com/eatssu/android/domain/usecase/user/UserDelegatingUseCasesBehaviorSpec.kt b/app/src/test/java/com/eatssu/android/domain/usecase/user/UserDelegatingUseCasesBehaviorSpec.kt
new file mode 100644
index 000000000..7c90851d6
--- /dev/null
+++ b/app/src/test/java/com/eatssu/android/domain/usecase/user/UserDelegatingUseCasesBehaviorSpec.kt
@@ -0,0 +1,190 @@
+package com.eatssu.android.domain.usecase.user
+
+import com.eatssu.android.data.local.AccountDataStore
+import com.eatssu.android.data.remote.dto.request.ChangeNicknameRequest
+import com.eatssu.android.domain.repository.UserRepository
+import com.eatssu.android.test.AppBehaviorSpec
+import com.eatssu.android.test.sampleCollege
+import com.eatssu.android.test.sampleDepartment
+import io.kotest.matchers.shouldBe
+import io.mockk.coEvery
+import io.mockk.coJustRun
+import io.mockk.coVerify
+import io.mockk.coVerifyOrder
+import io.mockk.every
+import io.mockk.mockk
+import kotlinx.coroutines.ExperimentalCoroutinesApi
+import kotlinx.coroutines.flow.flowOf
+import kotlinx.coroutines.test.runTest
+
+@OptIn(ExperimentalCoroutinesApi::class)
+class UserDelegatingUseCasesBehaviorSpec : AppBehaviorSpec({
+
+ given("GetUserCollegeDepartmentUseCase") {
+ val accountDataStore = mockk()
+ val useCase = GetUserCollegeDepartmentUseCase(accountDataStore)
+
+ `when`("로컬에 닉네임/단과대/학과가 모두 있으면") {
+ every { accountDataStore.name } returns flowOf("eatssu")
+ every { accountDataStore.college } returns flowOf(sampleCollege(1, "IT대학"))
+ every { accountDataStore.department } returns flowOf(sampleDepartment(2, "컴퓨터학부"))
+
+ then("해당 값을 UserInfo로 반환한다") {
+ runTest {
+ val result = useCase()
+ result.nickname shouldBe "eatssu"
+ result.userCollege shouldBe sampleCollege(1, "IT대학")
+ result.userDepartment shouldBe sampleDepartment(2, "컴퓨터학부")
+ }
+ }
+ }
+
+ `when`("단과대/학과가 비어있으면") {
+ every { accountDataStore.name } returns flowOf("eatssu")
+ every { accountDataStore.college } returns flowOf(null)
+ every { accountDataStore.department } returns flowOf(null)
+
+ then("기본 placeholder 값으로 채운다") {
+ runTest {
+ val result = useCase()
+ result.userCollege.collegeId shouldBe -1
+ result.userCollege.collegeName shouldBe "단과대"
+ result.userDepartment.departmentId shouldBe -1
+ result.userDepartment.departmentName shouldBe "학과"
+ }
+ }
+ }
+ }
+
+ given("GetUserNickNameUseCase") {
+ val userRepository = mockk()
+ val accountDataStore = mockk()
+ val useCase = GetUserNickNameUseCase(userRepository, accountDataStore)
+
+ `when`("로컬 닉네임이 비어있지 않으면") {
+ every { accountDataStore.name } returns flowOf("local-nick")
+
+ then("원격 조회 없이 로컬 닉네임을 반환한다") {
+ runTest {
+ useCase() shouldBe "local-nick"
+ coVerify(exactly = 0) { userRepository.getUserNickName() }
+ }
+ }
+ }
+
+ `when`("로컬 닉네임이 비어있으면") {
+ every { accountDataStore.name } returns flowOf("")
+ coEvery { userRepository.getUserNickName() } returns "remote-nick"
+ coJustRun { accountDataStore.setName("remote-nick") }
+
+ then("원격 닉네임을 조회해 저장 후 반환한다") {
+ runTest {
+ useCase() shouldBe "remote-nick"
+ coVerifyOrder {
+ userRepository.getUserNickName()
+ accountDataStore.setName("remote-nick")
+ }
+ }
+ }
+ }
+ }
+
+ given("SetUserCollegeDepartmentUseCase") {
+ val accountDataStore = mockk()
+ val useCase = SetUserCollegeDepartmentUseCase(accountDataStore)
+ val college = sampleCollege(5, "경영대학")
+ val department = sampleDepartment(9, "경영학부")
+
+ coJustRun { accountDataStore.setCollege(college) }
+ coJustRun { accountDataStore.setDepartment(department) }
+
+ `when`("단과대/학과를 전달하면") {
+ then("둘 다 저장한다") {
+ runTest {
+ useCase(college, department)
+ coVerify(exactly = 1) { accountDataStore.setCollege(college) }
+ coVerify(exactly = 1) { accountDataStore.setDepartment(department) }
+ }
+ }
+ }
+ }
+
+ given("SetUserEmailUseCase") {
+ val accountDataStore = mockk()
+ val useCase = SetUserEmailUseCase(accountDataStore)
+
+ coJustRun { accountDataStore.setEmail("a@b.com") }
+
+ `when`("이메일을 전달하면") {
+ then("로컬 이메일을 저장한다") {
+ runTest {
+ useCase("a@b.com")
+ coVerify(exactly = 1) { accountDataStore.setEmail("a@b.com") }
+ }
+ }
+ }
+ }
+
+ given("SetUserNicknameUseCase") {
+ val userRepository = mockk()
+ val accountDataStore = mockk()
+ val useCase = SetUserNicknameUseCase(userRepository, accountDataStore)
+ val nickname = "new-nick"
+ val request = ChangeNicknameRequest(nickname)
+
+ coJustRun { accountDataStore.setName(nickname) }
+
+ `when`("원격 닉네임 변경이 성공하면") {
+ coEvery { userRepository.updateUserName(request) } returns Result.success(Unit)
+
+ then("성공 결과를 반환하고 로컬 닉네임을 먼저 저장한다") {
+ runTest {
+ val result = useCase(nickname)
+ result.isSuccess shouldBe true
+ coVerifyOrder {
+ accountDataStore.setName(nickname)
+ userRepository.updateUserName(request)
+ }
+ }
+ }
+ }
+
+ `when`("원격 닉네임 변경이 실패하면") {
+ coEvery { userRepository.updateUserName(request) } returns Result.failure(IllegalStateException("fail"))
+
+ then("실패 결과를 반환해도 로컬 닉네임 저장은 수행한다") {
+ runTest {
+ val result = useCase(nickname)
+ result.isFailure shouldBe true
+ coVerify(exactly = 1) { accountDataStore.setName(nickname) }
+ coVerify(exactly = 1) { userRepository.updateUserName(request) }
+ }
+ }
+ }
+ }
+
+ given("ValidateNicknameServerUseCase") {
+ val userRepository = mockk()
+ val useCase = ValidateNicknameServerUseCase(userRepository)
+
+ `when`("서버 검증이 성공하면") {
+ coEvery { userRepository.checkUserNameValidation("valid-nick") } returns Result.success(Unit)
+
+ then("성공 결과를 그대로 반환한다") {
+ runTest {
+ useCase("valid-nick").isSuccess shouldBe true
+ }
+ }
+ }
+
+ `when`("서버 검증이 실패하면") {
+ coEvery { userRepository.checkUserNameValidation("bad-nick") } returns Result.failure(IllegalArgumentException("dup"))
+
+ then("실패 결과를 그대로 반환한다") {
+ runTest {
+ useCase("bad-nick").isFailure shouldBe true
+ }
+ }
+ }
+ }
+})
diff --git a/app/src/test/java/com/eatssu/android/domain/usecase/user/ValidateNicknameLocalUseCaseBehaviorSpec.kt b/app/src/test/java/com/eatssu/android/domain/usecase/user/ValidateNicknameLocalUseCaseBehaviorSpec.kt
new file mode 100644
index 000000000..629a97deb
--- /dev/null
+++ b/app/src/test/java/com/eatssu/android/domain/usecase/user/ValidateNicknameLocalUseCaseBehaviorSpec.kt
@@ -0,0 +1,86 @@
+package com.eatssu.android.domain.usecase.user
+
+import com.eatssu.android.R
+import com.eatssu.android.test.AppBehaviorSpec
+import com.eatssu.common.UiText
+import io.kotest.matchers.shouldBe
+
+private fun NicknameValidationResult.Invalid.resIdOrNull(): Int? =
+ (message as? UiText.StringResource)?.resId
+
+class ValidateNicknameLocalUseCaseBehaviorSpec : AppBehaviorSpec({
+
+ given("로컬 닉네임 검증") {
+ val useCase = ValidateNicknameLocalUseCase()
+
+ `when`("길이 제한을 벗어나면") {
+ then("length 에러를 반환한다") {
+ val result = useCase("a", minLength = 2, maxLength = 16)
+ (result is NicknameValidationResult.Invalid) shouldBe true
+ (result as NicknameValidationResult.Invalid).resIdOrNull() shouldBe R.string.nickname_error_length
+ }
+ }
+
+ `when`("탭/줄바꿈 등 공백 문자가 포함되면") {
+ then("whitespace 에러를 반환한다") {
+ val result = useCase("eat\tssu", 2, 16)
+ (result is NicknameValidationResult.Invalid) shouldBe true
+ (result as NicknameValidationResult.Invalid).resIdOrNull() shouldBe R.string.nickname_error_whitespace
+ }
+ }
+
+ `when`("연속 공백이 포함되면") {
+ then("consecutive space 에러를 반환한다") {
+ val result = useCase("eat ssu", 2, 16)
+ (result is NicknameValidationResult.Invalid) shouldBe true
+ (result as NicknameValidationResult.Invalid).resIdOrNull() shouldBe R.string.nickname_error_consecutive_space
+ }
+ }
+
+ `when`("허용되지 않은 문자가 포함되면") {
+ then("allowed chars 에러를 반환한다") {
+ val result = useCase("eat😀ssu", 2, 16)
+ (result is NicknameValidationResult.Invalid) shouldBe true
+ (result as NicknameValidationResult.Invalid).resIdOrNull() shouldBe R.string.nickname_error_allowed_chars
+ }
+ }
+
+ `when`("특수문자가 연속되면") {
+ then("consecutive special 에러를 반환한다") {
+ val result = useCase("eat__ssu", 2, 16)
+ (result is NicknameValidationResult.Invalid) shouldBe true
+ (result as NicknameValidationResult.Invalid).resIdOrNull() shouldBe R.string.nickname_error_consecutive_special
+ }
+ }
+
+ `when`("숫자로만 구성되면") {
+ then("only numbers 에러를 반환한다") {
+ val result = useCase("123456", 2, 16)
+ (result is NicknameValidationResult.Invalid) shouldBe true
+ (result as NicknameValidationResult.Invalid).resIdOrNull() shouldBe R.string.nickname_error_only_numbers
+ }
+ }
+
+ `when`("특수문자로 시작/종료하면") {
+ then("special position 에러를 반환한다") {
+ val result = useCase("_eatssu", 2, 16)
+ (result is NicknameValidationResult.Invalid) shouldBe true
+ (result as NicknameValidationResult.Invalid).resIdOrNull() shouldBe R.string.nickname_error_special_position
+ }
+ }
+
+ `when`("욕설/비속어 패턴이 포함되면") {
+ then("profanity 에러를 반환한다") {
+ val result = useCase("시발", 2, 16)
+ (result is NicknameValidationResult.Invalid) shouldBe true
+ (result as NicknameValidationResult.Invalid).resIdOrNull() shouldBe R.string.nickname_error_profanity
+ }
+ }
+
+ `when`("모든 조건을 만족하면") {
+ then("Valid를 반환한다") {
+ useCase("먹짱_23", 2, 16) shouldBe NicknameValidationResult.Valid
+ }
+ }
+ }
+})
diff --git a/app/src/test/java/com/eatssu/android/domain/usecase/widget/GetTodayMealUseCaseBehaviorSpec.kt b/app/src/test/java/com/eatssu/android/domain/usecase/widget/GetTodayMealUseCaseBehaviorSpec.kt
new file mode 100644
index 000000000..7223e9093
--- /dev/null
+++ b/app/src/test/java/com/eatssu/android/domain/usecase/widget/GetTodayMealUseCaseBehaviorSpec.kt
@@ -0,0 +1,82 @@
+package com.eatssu.android.domain.usecase.widget
+
+import com.eatssu.android.domain.repository.MealRepository
+import com.eatssu.android.presentation.widget.WidgetMealList
+import com.eatssu.android.test.AppBehaviorSpec
+import com.eatssu.common.enums.Restaurant
+import com.eatssu.common.enums.Time
+import io.kotest.matchers.shouldBe
+import io.mockk.coEvery
+import io.mockk.mockk
+import kotlinx.coroutines.ExperimentalCoroutinesApi
+import kotlinx.coroutines.test.runTest
+import java.net.UnknownHostException
+import java.nio.channels.UnresolvedAddressException
+
+@OptIn(ExperimentalCoroutinesApi::class)
+class GetTodayMealUseCaseBehaviorSpec : AppBehaviorSpec({
+
+ given("위젯 오늘의 식단 조회") {
+ val mealRepository = mockk()
+ val useCase = GetTodayMealUseCase(mealRepository)
+
+ `when`("아침/점심/저녁 조회가 모두 성공하면") {
+ val breakfast = listOf(listOf("아침A"))
+ val lunch = listOf(listOf("점심A", "점심B"))
+ val dinner = listOf(listOf("저녁A"))
+
+ coEvery { mealRepository.getTodayMeal("2025-01-01", "HAKSIK", Time.MORNING.name) } returns breakfast
+ coEvery { mealRepository.getTodayMeal("2025-01-01", "HAKSIK", Time.LUNCH.name) } returns lunch
+ coEvery { mealRepository.getTodayMeal("2025-01-01", "HAKSIK", Time.DINNER.name) } returns dinner
+
+ then("MealState.Success와 WidgetMealList를 반환한다") {
+ runTest {
+ useCase("2025-01-01", "HAKSIK") shouldBe MealState.Success(
+ WidgetMealList(
+ breakfast = breakfast to "breakfast",
+ lunch = lunch to "lunch",
+ dinner = dinner to "dinner",
+ restaurant = Restaurant.HAKSIK,
+ )
+ )
+ }
+ }
+ }
+
+ `when`("네트워크 주소 해석 예외가 발생하면") {
+ coEvery {
+ mealRepository.getTodayMeal("2025-01-01", "HAKSIK", Time.MORNING.name)
+ } throws UnknownHostException("offline")
+
+ then("MealState.Failure를 반환한다") {
+ runTest {
+ useCase("2025-01-01", "HAKSIK") shouldBe MealState.Failure
+ }
+ }
+ }
+
+ `when`("네트워크 주소 미해결 예외가 발생하면") {
+ coEvery {
+ mealRepository.getTodayMeal("2025-01-01", "HAKSIK", Time.MORNING.name)
+ } throws UnresolvedAddressException()
+
+ then("MealState.Failure를 반환한다") {
+ runTest {
+ useCase("2025-01-01", "HAKSIK") shouldBe MealState.Failure
+ }
+ }
+ }
+
+ `when`("알 수 없는 예외가 발생하면") {
+ coEvery {
+ mealRepository.getTodayMeal("2025-01-01", "HAKSIK", Time.MORNING.name)
+ } throws IllegalStateException("boom")
+
+ then("MealState.Failure를 반환한다") {
+ runTest {
+ useCase("2025-01-01", "HAKSIK") shouldBe MealState.Failure
+ }
+ }
+ }
+ }
+})
diff --git a/app/src/test/java/com/eatssu/android/domain/usecase/widget/WidgetRestaurantUseCasesBehaviorSpec.kt b/app/src/test/java/com/eatssu/android/domain/usecase/widget/WidgetRestaurantUseCasesBehaviorSpec.kt
new file mode 100644
index 000000000..998d62f1e
--- /dev/null
+++ b/app/src/test/java/com/eatssu/android/domain/usecase/widget/WidgetRestaurantUseCasesBehaviorSpec.kt
@@ -0,0 +1,56 @@
+package com.eatssu.android.domain.usecase.widget
+
+import com.eatssu.android.data.local.WidgetDataStore
+import com.eatssu.android.test.AppBehaviorSpec
+import com.eatssu.common.enums.Restaurant
+import io.kotest.matchers.shouldBe
+import io.mockk.coEvery
+import io.mockk.coJustRun
+import io.mockk.coVerify
+import io.mockk.mockk
+import kotlinx.coroutines.ExperimentalCoroutinesApi
+import kotlinx.coroutines.test.runTest
+
+@OptIn(ExperimentalCoroutinesApi::class)
+class WidgetRestaurantUseCasesBehaviorSpec : AppBehaviorSpec({
+
+ given("LoadRestaurantByFileKeyUseCase") {
+ val widgetDataStore = mockk()
+ val useCase = LoadRestaurantByFileKeyUseCase(widgetDataStore)
+
+ `when`("저장된 식당이 있으면") {
+ coEvery { widgetDataStore.loadRestaurantByFileKey("file-key") } returns Restaurant.HAKSIK
+
+ then("식당 enum을 반환한다") {
+ runTest {
+ useCase("file-key") shouldBe Restaurant.HAKSIK
+ }
+ }
+ }
+
+ `when`("저장된 식당이 없으면") {
+ coEvery { widgetDataStore.loadRestaurantByFileKey("file-key") } returns null
+
+ then("null을 반환한다") {
+ runTest {
+ useCase("file-key") shouldBe null
+ }
+ }
+ }
+ }
+
+ given("SaveRestaurantByFileKeyUseCase") {
+ val widgetDataStore = mockk()
+ val useCase = SaveRestaurantByFileKeyUseCase(widgetDataStore)
+ coJustRun { widgetDataStore.saveRestaurantByFileKey(any(), any()) }
+
+ `when`("fileKey와 식당을 전달하면") {
+ then("저장소에 그대로 위임한다") {
+ runTest {
+ useCase("file-key", Restaurant.DODAM)
+ coVerify(exactly = 1) { widgetDataStore.saveRestaurantByFileKey("file-key", Restaurant.DODAM) }
+ }
+ }
+ }
+ }
+})
diff --git a/app/src/test/java/com/eatssu/android/presentation/MainViewModelBehaviorSpec.kt b/app/src/test/java/com/eatssu/android/presentation/MainViewModelBehaviorSpec.kt
new file mode 100644
index 000000000..732c1f6e1
--- /dev/null
+++ b/app/src/test/java/com/eatssu/android/presentation/MainViewModelBehaviorSpec.kt
@@ -0,0 +1,162 @@
+package com.eatssu.android.presentation
+
+import app.cash.turbine.test
+import com.eatssu.android.R
+import com.eatssu.android.domain.model.College
+import com.eatssu.android.domain.model.Department
+import com.eatssu.android.domain.repository.UserRepository
+import com.eatssu.android.domain.usecase.auth.LogoutUseCase
+import com.eatssu.android.domain.usecase.user.GetUserCollegeDepartmentUseCase
+import com.eatssu.android.domain.usecase.user.GetUserNickNameUseCase
+import com.eatssu.android.domain.usecase.user.SetUserCollegeDepartmentUseCase
+import com.eatssu.android.test.AppBehaviorSpec
+import com.eatssu.android.test.expectToast
+import com.eatssu.android.test.sampleUserInfo
+import com.eatssu.common.UiState
+import com.eatssu.common.enums.ToastType
+import io.kotest.assertions.nondeterministic.eventually
+import io.kotest.matchers.shouldBe
+import io.mockk.coEvery
+import io.mockk.coVerify
+import io.mockk.mockk
+import kotlinx.coroutines.ExperimentalCoroutinesApi
+import kotlinx.coroutines.delay
+import kotlinx.coroutines.test.advanceUntilIdle
+import kotlinx.coroutines.test.runTest
+import kotlin.time.Duration.Companion.seconds
+
+@OptIn(ExperimentalCoroutinesApi::class)
+class MainViewModelBehaviorSpec : AppBehaviorSpec({
+
+ given("메인 화면") {
+ val logoutUseCase = mockk()
+ val getUserNickNameUseCase = mockk()
+ val setUserCollegeDepartmentUseCase = mockk()
+ val userRepository = mockk()
+ val getUserCollegeDepartmentUseCase = mockk()
+
+ val college = College(collegeId = 1, collegeName = "IT")
+ val department = Department(departmentId = 11, departmentName = "컴퓨터학부")
+ val userInfo = sampleUserInfo(
+ nickname = "eatssu",
+ college = college,
+ department = department,
+ )
+
+ coEvery { logoutUseCase() } returns Unit
+ coEvery { getUserNickNameUseCase() } returns "eatssu"
+ coEvery { getUserCollegeDepartmentUseCase() } returns userInfo
+ coEvery { userRepository.getUserCollegeDepartment() } returns (college to department)
+ coEvery { setUserCollegeDepartmentUseCase(college, department) } returns Unit
+
+ `when`("학과 정보를 새로고침하면") {
+ val viewModel = MainViewModel(
+ logoutUseCase = logoutUseCase,
+ getUserNickNameUseCase = getUserNickNameUseCase,
+ setUserCollegeDepartmentUseCase = setUserCollegeDepartmentUseCase,
+ userRepository = userRepository,
+ getUserCollegeDepartmentUseCase = getUserCollegeDepartmentUseCase,
+ )
+
+ then("부서명이 반영된 DepartmentState로 전이된다") {
+ runTest {
+ viewModel.refreshUserDepartment()
+ eventually(2.seconds) {
+ viewModel.uiState.value shouldBe UiState.Success(
+ MainState.DepartmentState(departmentName = "컴퓨터학부")
+ )
+ }
+ }
+ }
+ }
+
+ `when`("저장된 학과 정보가 없는 유저로 초기화되면") {
+ coEvery {
+ getUserCollegeDepartmentUseCase()
+ } returns sampleUserInfo(
+ nickname = "eatssu",
+ college = College(collegeId = -1, collegeName = "단과대"),
+ department = Department(departmentId = -1, departmentName = "학과"),
+ )
+ coEvery { getUserNickNameUseCase() } coAnswers {
+ delay(10_000)
+ "eatssu"
+ }
+ coEvery { userRepository.getUserCollegeDepartment() } coAnswers {
+ delay(1_000)
+ null
+ }
+
+ then("not found 토스트를 내보낸다") {
+ runTest {
+ val viewModel = MainViewModel(
+ logoutUseCase = logoutUseCase,
+ getUserNickNameUseCase = getUserNickNameUseCase,
+ setUserCollegeDepartmentUseCase = setUserCollegeDepartmentUseCase,
+ userRepository = userRepository,
+ getUserCollegeDepartmentUseCase = getUserCollegeDepartmentUseCase,
+ )
+
+ viewModel.uiEvent.test {
+ advanceUntilIdle()
+ expectToast(R.string.not_found, ToastType.ERROR)
+ cancelAndIgnoreRemainingEvents()
+ }
+ }
+ }
+ }
+
+ `when`("닉네임이 비어있는 유저로 초기화되면") {
+ coEvery { getUserCollegeDepartmentUseCase() } returns userInfo
+ coEvery { userRepository.getUserCollegeDepartment() } returns (college to department)
+ coEvery { getUserNickNameUseCase() } coAnswers {
+ delay(1_000)
+ " "
+ }
+
+ then("닉네임 설정 안내 토스트를 내보낸다") {
+ runTest {
+ val viewModel = MainViewModel(
+ logoutUseCase = logoutUseCase,
+ getUserNickNameUseCase = getUserNickNameUseCase,
+ setUserCollegeDepartmentUseCase = setUserCollegeDepartmentUseCase,
+ userRepository = userRepository,
+ getUserCollegeDepartmentUseCase = getUserCollegeDepartmentUseCase,
+ )
+
+ viewModel.uiEvent.test {
+ advanceUntilIdle()
+ expectToast(R.string.set_nickname, ToastType.ERROR)
+ cancelAndIgnoreRemainingEvents()
+ }
+ }
+ }
+ }
+
+ `when`("로그아웃을 수행하면") {
+ val viewModel = MainViewModel(
+ logoutUseCase = logoutUseCase,
+ getUserNickNameUseCase = getUserNickNameUseCase,
+ setUserCollegeDepartmentUseCase = setUserCollegeDepartmentUseCase,
+ userRepository = userRepository,
+ getUserCollegeDepartmentUseCase = getUserCollegeDepartmentUseCase,
+ )
+
+ then("로그아웃 유즈케이스 호출 후 성공 토스트와 LoggedOut 상태를 반영한다") {
+ runTest {
+ viewModel.uiEvent.test {
+ viewModel.logOut()
+
+ expectToast(R.string.toast_logout_success, ToastType.SUCCESS)
+ eventually(2.seconds) {
+ coVerify { logoutUseCase() }
+ viewModel.uiState.value shouldBe UiState.Success(MainState.LoggedOut)
+ }
+ cancelAndIgnoreRemainingEvents()
+ }
+ }
+ }
+ }
+
+ }
+})
diff --git a/app/src/test/java/com/eatssu/android/presentation/cafeteria/info/InfoViewModelBehaviorSpec.kt b/app/src/test/java/com/eatssu/android/presentation/cafeteria/info/InfoViewModelBehaviorSpec.kt
new file mode 100644
index 000000000..308c98cb6
--- /dev/null
+++ b/app/src/test/java/com/eatssu/android/presentation/cafeteria/info/InfoViewModelBehaviorSpec.kt
@@ -0,0 +1,48 @@
+package com.eatssu.android.presentation.cafeteria.info
+
+import com.eatssu.android.domain.model.RestaurantInfo
+import com.eatssu.android.domain.repository.FirebaseRemoteConfigRepository
+import com.eatssu.android.test.AppBehaviorSpec
+import com.eatssu.common.enums.Restaurant
+import io.kotest.matchers.nulls.shouldBeNull
+import io.kotest.matchers.shouldBe
+import io.mockk.coEvery
+import io.mockk.mockk
+import kotlinx.coroutines.test.runTest
+
+class InfoViewModelBehaviorSpec : AppBehaviorSpec({
+
+ given("식당 정보 조회") {
+ val repo = mockk()
+
+ `when`("원격 설정 조회가 성공하면") {
+ val info = RestaurantInfo(
+ enum = Restaurant.HAKSIK,
+ name = "학식",
+ location = "1층",
+ image = "img",
+ time = "09:00",
+ etc = "etc",
+ )
+ coEvery { repo.getRestaurantInfo(Restaurant.HAKSIK) } returns info
+ val viewModel = InfoViewModel(repo)
+
+ then("식당 정보를 반환한다") {
+ runTest {
+ viewModel.getRestaurantInfo(Restaurant.HAKSIK) shouldBe info
+ }
+ }
+ }
+
+ `when`("원격 설정 조회 중 예외가 발생하면") {
+ coEvery { repo.getRestaurantInfo(Restaurant.HAKSIK) } throws IllegalStateException("boom")
+ val viewModel = InfoViewModel(repo)
+
+ then("null을 반환한다") {
+ runTest {
+ viewModel.getRestaurantInfo(Restaurant.HAKSIK).shouldBeNull()
+ }
+ }
+ }
+ }
+})
diff --git a/app/src/test/java/com/eatssu/android/presentation/cafeteria/menu/MenuViewModelBehaviorSpec.kt b/app/src/test/java/com/eatssu/android/presentation/cafeteria/menu/MenuViewModelBehaviorSpec.kt
new file mode 100644
index 000000000..eb8e981f1
--- /dev/null
+++ b/app/src/test/java/com/eatssu/android/presentation/cafeteria/menu/MenuViewModelBehaviorSpec.kt
@@ -0,0 +1,58 @@
+package com.eatssu.android.presentation.cafeteria.menu
+
+import com.eatssu.android.domain.model.Menu
+import com.eatssu.android.domain.usecase.menu.GetMenuListUseCase
+import com.eatssu.android.test.AppBehaviorSpec
+import com.eatssu.common.UiState
+import com.eatssu.common.enums.Restaurant
+import com.eatssu.common.enums.Time
+import io.kotest.matchers.shouldBe
+import io.mockk.coEvery
+import io.mockk.coVerify
+import io.mockk.mockk
+import kotlinx.coroutines.ExperimentalCoroutinesApi
+import kotlinx.coroutines.test.advanceUntilIdle
+import kotlinx.coroutines.test.runTest
+
+@OptIn(ExperimentalCoroutinesApi::class)
+class MenuViewModelBehaviorSpec : AppBehaviorSpec({
+
+ given("메뉴 로드") {
+ val useCase = mockk()
+
+ `when`("식당 목록이 비어있으면") {
+ val viewModel = MenuViewModel(useCase)
+
+ then("빈 맵으로 성공 상태가 된다") {
+ runTest {
+ viewModel.loadMenus(emptyList(), "20250101", Time.LUNCH)
+ advanceUntilIdle()
+
+ viewModel.uiState.value shouldBe UiState.Success(MenuState(emptyMap()))
+ coVerify(exactly = 0) { useCase(any(), any(), any()) }
+ }
+ }
+ }
+
+ `when`("여러 식당에 대한 조회가 성공하면") {
+ val viewModel = MenuViewModel(useCase)
+ val r1 = Restaurant.FOOD_COURT
+ val r2 = Restaurant.HAKSIK
+ val m1 = listOf(Menu(id = 1, name = "A", price = 1000, rate = 4.0))
+ val m2 = listOf(Menu(id = 2, name = "B", price = 2000, rate = 3.5))
+ coEvery { useCase(r1, "20250101", Time.LUNCH) } returns m1
+ coEvery { useCase(r2, "20250101", Time.LUNCH) } returns m2
+
+ then("식당별 메뉴 맵으로 성공 상태가 된다") {
+ runTest {
+ viewModel.loadMenus(listOf(r1, r2), "20250101", Time.LUNCH)
+ advanceUntilIdle()
+
+ (viewModel.uiState.value is UiState.Success) shouldBe true
+ coVerify(exactly = 1) { useCase(r1, "20250101", Time.LUNCH) }
+ coVerify(exactly = 1) { useCase(r2, "20250101", Time.LUNCH) }
+ }
+ }
+ }
+ }
+})
diff --git a/app/src/test/java/com/eatssu/android/presentation/cafeteria/review/list/ReviewListViewModelBehaviorSpec.kt b/app/src/test/java/com/eatssu/android/presentation/cafeteria/review/list/ReviewListViewModelBehaviorSpec.kt
new file mode 100644
index 000000000..9b97e85f0
--- /dev/null
+++ b/app/src/test/java/com/eatssu/android/presentation/cafeteria/review/list/ReviewListViewModelBehaviorSpec.kt
@@ -0,0 +1,125 @@
+package com.eatssu.android.presentation.cafeteria.review.list
+
+import androidx.paging.PagingData
+import app.cash.turbine.test
+import com.eatssu.android.R
+import com.eatssu.android.domain.usecase.review.DeleteReviewUseCase
+import com.eatssu.android.domain.usecase.review.GetReviewInfoUseCase
+import com.eatssu.android.domain.usecase.review.GetReviewListPagedUseCase
+import com.eatssu.android.test.AppBehaviorSpec
+import com.eatssu.android.test.expectToast
+import com.eatssu.android.test.sampleReviewInfo
+import com.eatssu.common.UiState
+import com.eatssu.common.enums.MenuType
+import com.eatssu.common.enums.ToastType
+import io.kotest.matchers.shouldBe
+import io.mockk.coEvery
+import io.mockk.coVerify
+import io.mockk.every
+import io.mockk.mockk
+import kotlinx.coroutines.ExperimentalCoroutinesApi
+import kotlinx.coroutines.flow.flowOf
+import kotlinx.coroutines.test.advanceUntilIdle
+import kotlinx.coroutines.test.runTest
+
+@OptIn(ExperimentalCoroutinesApi::class)
+class ReviewListViewModelBehaviorSpec : AppBehaviorSpec({
+
+ given("리뷰 목록 화면") {
+ val getReviewInfoUseCase = mockk()
+ val getReviewListPagedUseCase = mockk()
+ val deleteReviewUseCase = mockk()
+
+ every { getReviewListPagedUseCase(any(), any()) } returns flowOf(PagingData.empty())
+
+ `when`("리뷰 정보를 정상 조회하면") {
+ val viewModel = ReviewListViewModel(getReviewInfoUseCase, getReviewListPagedUseCase, deleteReviewUseCase)
+ val info = sampleReviewInfo()
+ coEvery { getReviewInfoUseCase(MenuType.FIXED, 100L) } returns info
+
+ then("Success 상태가 된다") {
+ runTest {
+ viewModel.getReview(MenuType.FIXED, 100L)
+ advanceUntilIdle()
+
+ viewModel.uiState.value shouldBe UiState.Success(ReviewListState(info))
+ }
+ }
+ }
+
+ `when`("리뷰 정보 조회에서 예외가 발생하면") {
+ val viewModel = ReviewListViewModel(getReviewInfoUseCase, getReviewListPagedUseCase, deleteReviewUseCase)
+ coEvery { getReviewInfoUseCase(MenuType.VARIABLE, 101L) } throws IllegalStateException("boom")
+
+ then("Error 상태와 실패 토스트를 보낸다") {
+ runTest {
+ viewModel.uiEvent.test {
+ viewModel.getReview(MenuType.VARIABLE, 101L)
+ advanceUntilIdle()
+
+ viewModel.uiState.value shouldBe UiState.Error
+ expectToast(R.string.toast_review_load_failed, ToastType.ERROR)
+ cancelAndIgnoreRemainingEvents()
+ }
+ }
+ }
+ }
+
+ `when`("리뷰 삭제가 실패하면") {
+ val viewModel = ReviewListViewModel(getReviewInfoUseCase, getReviewListPagedUseCase, deleteReviewUseCase)
+ coEvery { deleteReviewUseCase(55L) } returns false
+
+ then("실패 토스트를 보낸다") {
+ runTest {
+ viewModel.uiEvent.test {
+ viewModel.deleteReview(55L)
+ advanceUntilIdle()
+
+ expectToast(R.string.toast_review_delete_failed, ToastType.ERROR)
+ cancelAndIgnoreRemainingEvents()
+ }
+ }
+ }
+ }
+
+ `when`("리뷰 삭제가 성공하면") {
+ val viewModel = ReviewListViewModel(getReviewInfoUseCase, getReviewListPagedUseCase, deleteReviewUseCase)
+ coEvery { getReviewInfoUseCase(MenuType.FIXED, 300L) } returns sampleReviewInfo(count = 3)
+ coEvery { deleteReviewUseCase(56L) } returns true
+
+ then("ReviewDeleted 이벤트를 보내고 현재 파라미터로 정보를 다시 로드한다") {
+ runTest {
+ viewModel.getReview(MenuType.FIXED, 300L)
+ advanceUntilIdle()
+
+ viewModel.uiEvent.test {
+ viewModel.deleteReview(56L)
+ advanceUntilIdle()
+
+ awaitItem() shouldBe ReviewListEvent.ReviewDeleted
+ coVerify(atLeast = 2) { getReviewInfoUseCase(MenuType.FIXED, 300L) }
+ cancelAndIgnoreRemainingEvents()
+ }
+ }
+ }
+ }
+
+ `when`("조회 파라미터 없이 리뷰 삭제가 성공하면") {
+ val viewModel = ReviewListViewModel(getReviewInfoUseCase, getReviewListPagedUseCase, deleteReviewUseCase)
+ coEvery { deleteReviewUseCase(77L) } returns true
+
+ then("ReviewDeleted 이벤트만 발생하고 정보 재조회는 하지 않는다") {
+ runTest {
+ viewModel.uiEvent.test {
+ viewModel.deleteReview(77L)
+ advanceUntilIdle()
+
+ awaitItem() shouldBe ReviewListEvent.ReviewDeleted
+ coVerify(exactly = 0) { getReviewInfoUseCase(any(), any()) }
+ cancelAndIgnoreRemainingEvents()
+ }
+ }
+ }
+ }
+ }
+})
diff --git a/app/src/test/java/com/eatssu/android/presentation/cafeteria/review/modify/ModifyViewModelBehaviorSpec.kt b/app/src/test/java/com/eatssu/android/presentation/cafeteria/review/modify/ModifyViewModelBehaviorSpec.kt
new file mode 100644
index 000000000..40ad9980a
--- /dev/null
+++ b/app/src/test/java/com/eatssu/android/presentation/cafeteria/review/modify/ModifyViewModelBehaviorSpec.kt
@@ -0,0 +1,113 @@
+package com.eatssu.android.presentation.cafeteria.review.modify
+
+import app.cash.turbine.test
+import com.eatssu.android.R
+import com.eatssu.android.domain.model.Review
+import com.eatssu.android.domain.usecase.review.ModifyReviewUseCase
+import com.eatssu.android.test.AppBehaviorSpec
+import com.eatssu.android.test.assertToast
+import com.eatssu.android.test.awaitToastEvent
+import com.eatssu.common.UiEvent
+import com.eatssu.common.UiState
+import com.eatssu.common.enums.ToastType
+import io.kotest.matchers.shouldBe
+import io.mockk.coEvery
+import io.mockk.coVerify
+import io.mockk.mockk
+import kotlinx.coroutines.ExperimentalCoroutinesApi
+import kotlinx.coroutines.test.advanceUntilIdle
+import kotlinx.coroutines.test.runTest
+
+@OptIn(ExperimentalCoroutinesApi::class)
+class ModifyViewModelBehaviorSpec : AppBehaviorSpec({
+
+ val likes = listOf(
+ Review.MenuLikeInfo(menuId = 1L, name = "A", isLike = true),
+ Review.MenuLikeInfo(menuId = 2L, name = "B", isLike = false),
+ )
+
+ given("리뷰 수정 폼") {
+ val useCase = mockk()
+
+ `when`("init을 호출하면") {
+ val viewModel = ModifyViewModel(useCase)
+
+ then("Editing 상태와 baseline이 초기화된다") {
+ viewModel.init(4, "old", likes)
+
+ viewModel.uiState.value shouldBe UiState.Success(
+ ModifyState.Editing(
+ rating = 4,
+ content = "old",
+ menuLikeInfos = likes,
+ baseline = ModifyState.Baseline(4, "old", likes),
+ )
+ )
+ }
+ }
+
+ `when`("변경사항이 없거나 rating이 0이면 submit하면") {
+ val viewModel = ModifyViewModel(useCase)
+ viewModel.init(4, "old", likes)
+
+ then("아무 요청도 보내지 않는다") {
+ runTest {
+ viewModel.submit(9L)
+ advanceUntilIdle()
+ coVerify(exactly = 0) { useCase(any(), any(), any(), any()) }
+ }
+ }
+
+ then("rating을 0으로 바꿔도 요청하지 않는다") {
+ runTest {
+ viewModel.onRatingChanged(0)
+ viewModel.submit(9L)
+ advanceUntilIdle()
+ coVerify(exactly = 0) { useCase(any(), any(), any(), any()) }
+ }
+ }
+ }
+
+ `when`("수정이 성공하면") {
+ val viewModel = ModifyViewModel(useCase)
+ viewModel.init(4, "old", likes)
+ viewModel.onContentChanged("new")
+ coEvery { useCase(10L, 4, "new", any()) } returns true
+
+ then("뒤로가기와 성공 토스트를 보낸다") {
+ runTest {
+ viewModel.uiEvent.test {
+ viewModel.submit(10L)
+ advanceUntilIdle()
+
+ awaitItem() shouldBe UiEvent.NavigateBack
+ awaitToastEvent().assertToast(R.string.toast_review_modify_success, ToastType.SUCCESS)
+ cancelAndIgnoreRemainingEvents()
+ }
+ }
+ }
+ }
+
+ `when`("수정이 실패하면") {
+ val useCase2 = mockk()
+ val viewModel = ModifyViewModel(useCase2)
+ viewModel.init(4, "old", likes)
+ viewModel.onContentChanged("new")
+ coEvery { useCase2(11L, 4, "new", any()) } returns false
+
+ then("현재 동작(characterization): 실패 토스트 후에도 뒤로가기+성공 토스트를 보낸다") {
+ runTest {
+ viewModel.uiEvent.test {
+ viewModel.submit(11L)
+ advanceUntilIdle()
+
+ awaitToastEvent().assertToast(R.string.toast_review_modify_failed, ToastType.ERROR)
+ awaitItem() shouldBe UiEvent.NavigateBack
+ awaitToastEvent().assertToast(R.string.toast_review_modify_success, ToastType.SUCCESS)
+ cancelAndIgnoreRemainingEvents()
+ }
+ }
+ }
+ }
+ }
+})
diff --git a/app/src/test/java/com/eatssu/android/presentation/cafeteria/review/report/ReportViewModelBehaviorSpec.kt b/app/src/test/java/com/eatssu/android/presentation/cafeteria/review/report/ReportViewModelBehaviorSpec.kt
new file mode 100644
index 000000000..1dfc4a2fc
--- /dev/null
+++ b/app/src/test/java/com/eatssu/android/presentation/cafeteria/review/report/ReportViewModelBehaviorSpec.kt
@@ -0,0 +1,58 @@
+package com.eatssu.android.presentation.cafeteria.review.report
+
+import com.eatssu.android.R
+import com.eatssu.android.data.remote.dto.request.ReportRequest
+import com.eatssu.android.domain.usecase.review.PostReportUseCase
+import com.eatssu.android.test.AppBehaviorSpec
+import com.eatssu.android.test.asStringResIdOrNull
+import io.kotest.matchers.shouldBe
+import io.mockk.coEvery
+import io.mockk.coVerify
+import io.mockk.mockk
+import kotlinx.coroutines.ExperimentalCoroutinesApi
+import kotlinx.coroutines.test.advanceUntilIdle
+import kotlinx.coroutines.test.runTest
+
+@OptIn(ExperimentalCoroutinesApi::class)
+class ReportViewModelBehaviorSpec : AppBehaviorSpec({
+
+ given("신고 전송") {
+ val postReportUseCase = mockk()
+
+ `when`("신고가 실패하면") {
+ coEvery { postReportUseCase(any()) } returns false
+ val viewModel = ReportViewModel(postReportUseCase)
+
+ then("error=true와 실패 토스트를 설정한다") {
+ runTest {
+ viewModel.postData(1L, "COPY", "bad")
+ advanceUntilIdle()
+
+ viewModel.uiState.value.loading shouldBe false
+ viewModel.uiState.value.error shouldBe true
+ viewModel.uiState.value.toastMessage.asStringResIdOrNull() shouldBe R.string.toast_report_failed
+ viewModel.uiState.value.isDone shouldBe false
+
+ coVerify { postReportUseCase(ReportRequest(1L, "COPY", "bad")) }
+ }
+ }
+ }
+
+ `when`("신고가 성공하면") {
+ coEvery { postReportUseCase(any()) } returns true
+ val viewModel = ReportViewModel(postReportUseCase)
+
+ then("isDone=true와 성공 토스트를 설정한다") {
+ runTest {
+ viewModel.postData(2L, "EXTRA", "spam")
+ advanceUntilIdle()
+
+ viewModel.uiState.value.loading shouldBe false
+ viewModel.uiState.value.error shouldBe false
+ viewModel.uiState.value.toastMessage.asStringResIdOrNull() shouldBe R.string.toast_report_success
+ viewModel.uiState.value.isDone shouldBe true
+ }
+ }
+ }
+ }
+})
diff --git a/app/src/test/java/com/eatssu/android/presentation/cafeteria/review/write/WriteReviewViewModelBehaviorSpec.kt b/app/src/test/java/com/eatssu/android/presentation/cafeteria/review/write/WriteReviewViewModelBehaviorSpec.kt
new file mode 100644
index 000000000..696449546
--- /dev/null
+++ b/app/src/test/java/com/eatssu/android/presentation/cafeteria/review/write/WriteReviewViewModelBehaviorSpec.kt
@@ -0,0 +1,337 @@
+package com.eatssu.android.presentation.cafeteria.review.write
+
+import android.content.ContentResolver
+import android.content.Context
+import android.net.Uri
+import app.cash.turbine.test
+import com.eatssu.android.R
+import com.eatssu.android.domain.model.MenuMini
+import com.eatssu.android.domain.usecase.menu.GetValidMenusOfMealUseCase
+import com.eatssu.android.domain.usecase.review.GetImageUrlUseCase
+import com.eatssu.android.domain.usecase.review.WriteReviewUseCase
+import com.eatssu.android.test.AppBehaviorSpec
+import com.eatssu.android.test.expectNavigateBack
+import com.eatssu.android.test.expectToast
+import com.eatssu.android.test.successDataAs
+import com.eatssu.common.EventLogger
+import com.eatssu.common.UiState
+import com.eatssu.common.enums.MenuType
+import com.eatssu.common.enums.ToastType
+import id.zelory.compressor.Compressor
+import io.kotest.matchers.shouldBe
+import io.mockk.Runs
+import io.mockk.coEvery
+import io.mockk.coVerify
+import io.mockk.clearMocks
+import io.mockk.every
+import io.mockk.just
+import io.mockk.mockk
+import io.mockk.mockkObject
+import kotlinx.coroutines.ExperimentalCoroutinesApi
+import kotlinx.coroutines.test.advanceUntilIdle
+import kotlinx.coroutines.test.runTest
+import java.io.ByteArrayInputStream
+import java.io.File
+
+@OptIn(ExperimentalCoroutinesApi::class)
+class WriteReviewViewModelBehaviorSpec : AppBehaviorSpec({
+
+ given("리뷰 작성 화면") {
+ val writeReviewUseCase = mockk()
+ val getImageUrlUseCase = mockk()
+ val getValidMenusOfMealUseCase = mockk()
+
+ `when`("고정 메뉴 타입을 로드하면") {
+ val viewModel = WriteReviewViewModel(writeReviewUseCase, getImageUrlUseCase, getValidMenusOfMealUseCase)
+
+ then("단일 메뉴 Editing 상태를 만든다") {
+ runTest {
+ viewModel.loadMenuList(MenuType.FIXED, 1L, "돈가스")
+ advanceUntilIdle()
+
+ viewModel.uiState.value shouldBe UiState.Success(
+ WriteReviewState.Editing(
+ menuList = listOf(MenuMini(1L, "돈가스")),
+ rating = 0,
+ content = "",
+ likedMenuIds = emptySet(),
+ selectedImageUri = null,
+ )
+ )
+ }
+ }
+ }
+
+ `when`("가변 메뉴 타입을 로드하면") {
+ val viewModel = WriteReviewViewModel(writeReviewUseCase, getImageUrlUseCase, getValidMenusOfMealUseCase)
+ val menus = listOf(MenuMini(10L, "A"), MenuMini(11L, "B"))
+ coEvery { getValidMenusOfMealUseCase(999L) } returns menus
+
+ then("usecase 결과로 Editing 상태를 만든다") {
+ runTest {
+ viewModel.loadMenuList(MenuType.VARIABLE, 999L, "")
+ advanceUntilIdle()
+
+ (viewModel.uiState.value as UiState.Success).data shouldBe WriteReviewState.Editing(
+ menuList = menus,
+ rating = 0,
+ content = "",
+ likedMenuIds = emptySet(),
+ selectedImageUri = null,
+ )
+ }
+ }
+ }
+
+ `when`("rating이 0인 상태에서 submit하면") {
+ val viewModel = WriteReviewViewModel(writeReviewUseCase, getImageUrlUseCase, getValidMenusOfMealUseCase)
+
+ then("요청하지 않는다") {
+ runTest {
+ viewModel.loadMenuList(MenuType.FIXED, 1L, "돈가스")
+ advanceUntilIdle()
+
+ viewModel.postReview(MenuType.FIXED, 1L, mockk(relaxed = true))
+ advanceUntilIdle()
+
+ coVerify(exactly = 0) {
+ writeReviewUseCase(any(), any(), any(), any(), any(), any())
+ }
+ }
+ }
+ }
+
+ `when`("Editing 상태가 아닐 때 submit하면") {
+ val viewModel = WriteReviewViewModel(writeReviewUseCase, getImageUrlUseCase, getValidMenusOfMealUseCase)
+
+ then("아무 동작도 수행하지 않는다") {
+ runTest {
+ viewModel.postReview(MenuType.FIXED, 1L, mockk(relaxed = true))
+ advanceUntilIdle()
+
+ viewModel.uiState.value shouldBe UiState.Init
+ coVerify(exactly = 0) {
+ writeReviewUseCase(any(), any(), any(), any(), any(), any())
+ }
+ }
+ }
+ }
+
+ `when`("좋아요 메뉴를 같은 id로 두 번 토글하면") {
+ val viewModel = WriteReviewViewModel(writeReviewUseCase, getImageUrlUseCase, getValidMenusOfMealUseCase)
+
+ then("likedMenuIds가 추가됐다가 다시 제거된다") {
+ runTest {
+ viewModel.loadMenuList(MenuType.FIXED, 1L, "돈가스")
+ advanceUntilIdle()
+
+ viewModel.toggleLike(101L)
+ viewModel.uiState.value.successDataAs().likedMenuIds shouldBe setOf(101L)
+
+ viewModel.toggleLike(101L)
+ viewModel.uiState.value.successDataAs().likedMenuIds shouldBe emptySet()
+ }
+ }
+ }
+
+ `when`("이미지 없이 리뷰 작성이 성공하면") {
+ val viewModel = WriteReviewViewModel(writeReviewUseCase, getImageUrlUseCase, getValidMenusOfMealUseCase)
+ coEvery {
+ writeReviewUseCase(
+ menuType = MenuType.FIXED,
+ itemId = 1L,
+ rating = 5,
+ content = "good",
+ imageUrl = null,
+ likeMenuIdList = any(),
+ )
+ } returns true
+ mockkObject(EventLogger)
+ every { EventLogger.completeReview(any(), any(), any()) } just Runs
+
+ then("성공 토스트와 NavigateBack 이벤트를 보낸다") {
+ runTest {
+ viewModel.loadMenuList(MenuType.FIXED, 1L, "돈가스")
+ advanceUntilIdle()
+ viewModel.onRatingChanged(5)
+ viewModel.onContentChanged("good")
+
+ viewModel.uiEvent.test {
+ viewModel.postReview(MenuType.FIXED, 1L, mockk(relaxed = true))
+ advanceUntilIdle()
+
+ expectToast(R.string.toast_review_write_success, ToastType.SUCCESS)
+ expectNavigateBack()
+ cancelAndIgnoreRemainingEvents()
+ }
+ }
+ }
+ }
+
+ `when`("이미지 업로드 성공 후 리뷰 작성이 성공하면") {
+ val viewModel = WriteReviewViewModel(writeReviewUseCase, getImageUrlUseCase, getValidMenusOfMealUseCase)
+ val context = mockk()
+ val resolver = mockk()
+ val uri = mockk()
+ val cacheDir = createTempDir(prefix = "write-review")
+ val compressed = File(cacheDir, "compressed.jpg").apply { writeBytes(byteArrayOf(1, 2, 3)) }
+
+ every { context.contentResolver } returns resolver
+ every { context.cacheDir } returns cacheDir
+ every { resolver.openInputStream(uri) } returns ByteArrayInputStream(byteArrayOf(1, 2, 3))
+
+ mockkObject(Compressor)
+ coEvery { Compressor.compress(context, any()) } returns compressed
+ coEvery { getImageUrlUseCase(compressed) } returns "https://img"
+ coEvery {
+ writeReviewUseCase(MenuType.FIXED, 1L, 4, "", "https://img", any())
+ } returns true
+ mockkObject(EventLogger)
+ every { EventLogger.completeReview(any(), any(), any()) } just Runs
+
+ then("이미지 업로드 성공 토스트 후 리뷰 성공 토스트와 뒤로가기를 보낸다") {
+ runTest {
+ viewModel.loadMenuList(MenuType.FIXED, 1L, "돈가스")
+ advanceUntilIdle()
+ viewModel.onRatingChanged(4)
+ viewModel.setSelectedImage(uri)
+ clearMocks(writeReviewUseCase, answers = false, recordedCalls = true)
+
+ viewModel.uiEvent.test {
+ viewModel.postReview(MenuType.FIXED, 1L, context)
+ advanceUntilIdle()
+
+ expectToast(R.string.toast_image_upload_success, ToastType.SUCCESS)
+ expectToast(R.string.toast_review_write_success, ToastType.SUCCESS)
+ expectNavigateBack()
+ cancelAndIgnoreRemainingEvents()
+ }
+ }
+ }
+ }
+
+ `when`("이미지 업로드 URL이 null이어도 리뷰 작성이 성공하면") {
+ val viewModel = WriteReviewViewModel(writeReviewUseCase, getImageUrlUseCase, getValidMenusOfMealUseCase)
+ val context = mockk()
+ val resolver = mockk()
+ val uri = mockk()
+ val cacheDir = createTempDir(prefix = "write-review-null-url")
+ val compressed = File(cacheDir, "compressed.jpg").apply { writeBytes(byteArrayOf(1, 2, 3)) }
+
+ every { context.contentResolver } returns resolver
+ every { context.cacheDir } returns cacheDir
+ every { resolver.openInputStream(uri) } returns ByteArrayInputStream(byteArrayOf(1, 2, 3))
+
+ mockkObject(Compressor)
+ coEvery { Compressor.compress(context, any()) } returns compressed
+ coEvery { getImageUrlUseCase(compressed) } returns null
+ coEvery {
+ writeReviewUseCase(MenuType.FIXED, 1L, 4, "", null, any())
+ } returns true
+ mockkObject(EventLogger)
+ every { EventLogger.completeReview(any(), any(), any()) } just Runs
+
+ then("현재 동작대로 이미지 업로드 성공 토스트 후 리뷰 성공 흐름을 유지한다") {
+ runTest {
+ viewModel.loadMenuList(MenuType.FIXED, 1L, "돈가스")
+ advanceUntilIdle()
+ viewModel.onRatingChanged(4)
+ viewModel.setSelectedImage(uri)
+
+ viewModel.uiEvent.test {
+ viewModel.postReview(MenuType.FIXED, 1L, context)
+ advanceUntilIdle()
+
+ expectToast(R.string.toast_image_upload_success, ToastType.SUCCESS)
+ expectToast(R.string.toast_review_write_success, ToastType.SUCCESS)
+ expectNavigateBack()
+ cancelAndIgnoreRemainingEvents()
+ }
+ }
+ }
+ }
+
+ `when`("이미지 압축이 실패하면") {
+ val viewModel = WriteReviewViewModel(writeReviewUseCase, getImageUrlUseCase, getValidMenusOfMealUseCase)
+ val context = mockk()
+ val resolver = mockk()
+ val uri = mockk()
+ val cacheDir = createTempDir(prefix = "write-review-fail")
+
+ every { context.contentResolver } returns resolver
+ every { context.cacheDir } returns cacheDir
+ every { resolver.openInputStream(uri) } returns ByteArrayInputStream(byteArrayOf(1, 2, 3))
+
+ mockkObject(Compressor)
+ coEvery { Compressor.compress(context, any()) } throws IllegalStateException("compress")
+
+ then("기존 Editing 상태로 롤백하고 압축 실패 토스트를 보낸다") {
+ runTest {
+ viewModel.loadMenuList(MenuType.FIXED, 1L, "돈가스")
+ advanceUntilIdle()
+ viewModel.onRatingChanged(4)
+ viewModel.setSelectedImage(uri)
+
+ viewModel.uiEvent.test {
+ viewModel.postReview(MenuType.FIXED, 1L, context)
+ advanceUntilIdle()
+
+ expectToast(R.string.toast_image_compress_failed, ToastType.ERROR)
+ viewModel.uiState.value.successDataAs()
+ coVerify(exactly = 0) { writeReviewUseCase(any(), any(), any(), any(), any(), any()) }
+ cancelAndIgnoreRemainingEvents()
+ }
+ }
+ }
+ }
+
+ `when`("이미지 업로드 과정에서 예외가 발생하면") {
+ val viewModel = WriteReviewViewModel(writeReviewUseCase, getImageUrlUseCase, getValidMenusOfMealUseCase)
+ val context = mockk()
+ val resolver = mockk()
+ val uri = mockk()
+
+ every { context.contentResolver } returns resolver
+ every { resolver.openInputStream(uri) } returns null
+
+ then("업로드 실패 토스트를 보낸다") {
+ runTest {
+ viewModel.loadMenuList(MenuType.FIXED, 1L, "돈가스")
+ advanceUntilIdle()
+ viewModel.onRatingChanged(4)
+ viewModel.setSelectedImage(uri)
+
+ viewModel.uiEvent.test {
+ viewModel.postReview(MenuType.FIXED, 1L, context)
+ advanceUntilIdle()
+
+ expectToast(R.string.toast_image_upload_failed, ToastType.ERROR)
+ cancelAndIgnoreRemainingEvents()
+ }
+ }
+ }
+ }
+
+ `when`("리뷰 작성 API가 실패하면") {
+ val viewModel = WriteReviewViewModel(writeReviewUseCase, getImageUrlUseCase, getValidMenusOfMealUseCase)
+ coEvery { writeReviewUseCase(MenuType.FIXED, 1L, 3, "", null, any()) } returns false
+
+ then("Editing으로 롤백하고 실패 토스트를 보낸다") {
+ runTest {
+ viewModel.loadMenuList(MenuType.FIXED, 1L, "돈가스")
+ advanceUntilIdle()
+ viewModel.onRatingChanged(3)
+
+ viewModel.uiEvent.test {
+ viewModel.postReview(MenuType.FIXED, 1L, mockk(relaxed = true))
+ advanceUntilIdle()
+
+ viewModel.uiState.value.successDataAs()
+ expectToast(R.string.toast_review_write_failed, ToastType.ERROR)
+ cancelAndIgnoreRemainingEvents()
+ }
+ }
+ }
+ }
+ }
+})
diff --git a/app/src/test/java/com/eatssu/android/presentation/intro/IntroViewModelBehaviorSpec.kt b/app/src/test/java/com/eatssu/android/presentation/intro/IntroViewModelBehaviorSpec.kt
new file mode 100644
index 000000000..530cad709
--- /dev/null
+++ b/app/src/test/java/com/eatssu/android/presentation/intro/IntroViewModelBehaviorSpec.kt
@@ -0,0 +1,122 @@
+package com.eatssu.android.presentation.intro
+
+import app.cash.turbine.test
+import com.eatssu.android.BuildConfig
+import com.eatssu.android.R
+import com.eatssu.android.domain.repository.FirebaseRemoteConfigRepository
+import com.eatssu.android.domain.usecase.auth.GetAccessTokenUseCase
+import com.eatssu.android.domain.usecase.health.HealthCheckUseCase
+import com.eatssu.android.test.AppBehaviorSpec
+import com.eatssu.android.test.assertToast
+import com.eatssu.android.test.awaitToastEvent
+import com.eatssu.common.UiState
+import com.eatssu.common.enums.ToastType
+import io.kotest.assertions.nondeterministic.eventually
+import io.kotest.matchers.shouldBe
+import io.mockk.coEvery
+import io.mockk.every
+import io.mockk.mockk
+import kotlinx.coroutines.ExperimentalCoroutinesApi
+import kotlinx.coroutines.delay
+import kotlinx.coroutines.test.runTest
+import kotlin.time.Duration.Companion.seconds
+
+@OptIn(ExperimentalCoroutinesApi::class)
+class IntroViewModelBehaviorSpec : AppBehaviorSpec({
+
+ given("앱 초기화") {
+ val healthCheckUseCase = mockk()
+ val getAccessTokenUseCase = mockk()
+ val firebaseRemoteConfigRepository = mockk()
+
+ `when`("강제 업데이트가 필요하고 토큰이 유효하면") {
+ val minimumVersion = (BuildConfig.VERSION_CODE + 1).toLong()
+ coEvery { firebaseRemoteConfigRepository.getMinimumVersionCode() } returns minimumVersion
+ coEvery { healthCheckUseCase() } returns true
+ every { getAccessTokenUseCase() } returns "valid-token"
+
+ val viewModel = IntroViewModel(
+ healthCheckUseCase = healthCheckUseCase,
+ getAccessTokenUseCase = getAccessTokenUseCase,
+ firebaseRemoteConfigRepository = firebaseRemoteConfigRepository,
+ )
+
+ then("강제 업데이트 결과와 유효 토큰 상태가 반영된다") {
+ runTest {
+ eventually(2.seconds) {
+ viewModel.versionCheckResult.value shouldBe VersionCheckResult.ForceUpdateRequired(minimumVersion)
+ viewModel.uiState.value shouldBe UiState.Success(IntroState.ValidToken)
+ }
+ }
+ }
+ }
+
+ `when`("헬스체크가 실패하면") {
+ coEvery { firebaseRemoteConfigRepository.getMinimumVersionCode() } returns BuildConfig.VERSION_CODE.toLong()
+ coEvery { healthCheckUseCase() } returns false
+ every { getAccessTokenUseCase() } returns "unused"
+
+ val viewModel = IntroViewModel(
+ healthCheckUseCase = healthCheckUseCase,
+ getAccessTokenUseCase = getAccessTokenUseCase,
+ firebaseRemoteConfigRepository = firebaseRemoteConfigRepository,
+ )
+
+ then("유효 토큰 성공 상태로 전이되지 않는다") {
+ runTest {
+ eventually(2.seconds) {
+ (viewModel.uiState.value == UiState.Success(IntroState.ValidToken)) shouldBe false
+ }
+ }
+ }
+ }
+
+ `when`("헬스체크 성공이지만 access token이 비어있으면") {
+ coEvery { firebaseRemoteConfigRepository.getMinimumVersionCode() } returns BuildConfig.VERSION_CODE.toLong()
+ coEvery { healthCheckUseCase() } coAnswers {
+ delay(50)
+ true
+ }
+ every { getAccessTokenUseCase() } returns ""
+
+ val viewModel = IntroViewModel(
+ healthCheckUseCase = healthCheckUseCase,
+ getAccessTokenUseCase = getAccessTokenUseCase,
+ firebaseRemoteConfigRepository = firebaseRemoteConfigRepository,
+ )
+
+ then("토큰 오류 토스트를 보내고 Error 상태가 된다") {
+ runTest {
+ viewModel.uiEvent.test {
+ awaitToastEvent().assertToast(R.string.toast_token_invalid, ToastType.INFO)
+ eventually(2.seconds) {
+ viewModel.uiState.value shouldBe UiState.Error
+ }
+ cancelAndIgnoreRemainingEvents()
+ }
+ }
+ }
+ }
+
+ `when`("버전 체크 중 예외가 발생해도") {
+ coEvery { firebaseRemoteConfigRepository.getMinimumVersionCode() } throws IllegalStateException("boom")
+ coEvery { healthCheckUseCase() } returns true
+ every { getAccessTokenUseCase() } returns "valid-token"
+
+ val viewModel = IntroViewModel(
+ healthCheckUseCase = healthCheckUseCase,
+ getAccessTokenUseCase = getAccessTokenUseCase,
+ firebaseRemoteConfigRepository = firebaseRemoteConfigRepository,
+ )
+
+ then("자동 로그인은 계속 진행되어 성공 상태가 된다") {
+ runTest {
+ eventually(2.seconds) {
+ viewModel.versionCheckResult.value shouldBe null
+ viewModel.uiState.value shouldBe UiState.Success(IntroState.ValidToken)
+ }
+ }
+ }
+ }
+ }
+})
diff --git a/app/src/test/java/com/eatssu/android/presentation/login/LoginViewModelBehaviorSpec.kt b/app/src/test/java/com/eatssu/android/presentation/login/LoginViewModelBehaviorSpec.kt
new file mode 100644
index 000000000..4707d436e
--- /dev/null
+++ b/app/src/test/java/com/eatssu/android/presentation/login/LoginViewModelBehaviorSpec.kt
@@ -0,0 +1,113 @@
+package com.eatssu.android.presentation.login
+
+import app.cash.turbine.test
+import com.eatssu.android.R
+import com.eatssu.android.domain.usecase.auth.LoginUseCase
+import com.eatssu.android.domain.usecase.auth.SetAccessTokenUseCase
+import com.eatssu.android.domain.usecase.auth.SetRefreshTokenUseCase
+import com.eatssu.android.domain.usecase.user.SetUserEmailUseCase
+import com.eatssu.android.test.AppBehaviorSpec
+import com.eatssu.android.test.assertToast
+import com.eatssu.android.test.awaitToastEvent
+import com.eatssu.android.test.sampleToken
+import com.eatssu.common.UiState
+import com.eatssu.common.enums.DeviceType
+import com.eatssu.common.enums.ToastType
+import io.kotest.assertions.nondeterministic.eventually
+import io.kotest.matchers.shouldBe
+import io.mockk.Runs
+import io.mockk.coEvery
+import io.mockk.coVerify
+import io.mockk.every
+import io.mockk.just
+import io.mockk.mockk
+import io.mockk.verify
+import kotlinx.coroutines.ExperimentalCoroutinesApi
+import kotlinx.coroutines.test.advanceUntilIdle
+import kotlinx.coroutines.test.runTest
+import kotlin.time.Duration.Companion.seconds
+
+@OptIn(ExperimentalCoroutinesApi::class)
+class LoginViewModelBehaviorSpec : AppBehaviorSpec({
+
+ given("카카오 로그인") {
+ val loginUseCase = mockk()
+ val setAccessTokenUseCase = mockk()
+ val setRefreshTokenUseCase = mockk()
+ val setUserEmailUseCase = mockk()
+
+ every { setAccessTokenUseCase(any()) } just Runs
+ every { setRefreshTokenUseCase(any()) } just Runs
+ coEvery { setUserEmailUseCase(any()) } just Runs
+
+ `when`("토큰 발급이 실패하면") {
+ val viewModel = LoginViewModel(
+ loginUseCase = loginUseCase,
+ setAccessTokenUseCase = setAccessTokenUseCase,
+ setRefreshTokenUseCase = setRefreshTokenUseCase,
+ setUserEmailUseCase = setUserEmailUseCase,
+ )
+ coEvery { loginUseCase("a@b.com", "pid", DeviceType.ANDROID) } returns null
+
+ then("Error 상태와 실패 토스트 이벤트를 방출한다") {
+ runTest {
+ viewModel.uiEvent.test {
+ viewModel.getKakaoLogin("a@b.com", "pid")
+ awaitToastEvent().assertToast(R.string.toast_login_failed, ToastType.ERROR)
+ eventually(2.seconds) {
+ viewModel.uiState.value shouldBe UiState.Error
+ }
+ cancelAndIgnoreRemainingEvents()
+ }
+ }
+ }
+ }
+
+ `when`("토큰 발급이 성공하면") {
+ val viewModel = LoginViewModel(
+ loginUseCase = loginUseCase,
+ setAccessTokenUseCase = setAccessTokenUseCase,
+ setRefreshTokenUseCase = setRefreshTokenUseCase,
+ setUserEmailUseCase = setUserEmailUseCase,
+ )
+ val token = sampleToken("acc", "ref")
+ coEvery { loginUseCase("a@b.com", "pid", DeviceType.ANDROID) } returns token
+
+ then("토큰과 이메일을 저장하고 성공 상태가 된다") {
+ runTest {
+ viewModel.getKakaoLogin("a@b.com", "pid")
+
+ eventually(2.seconds) {
+ verify { setAccessTokenUseCase("acc") }
+ verify { setRefreshTokenUseCase("ref") }
+ coVerify { setUserEmailUseCase("a@b.com") }
+ viewModel.uiState.value shouldBe UiState.Success(LoginState.LoginSuccess)
+ }
+ }
+ }
+ }
+ }
+
+ given("상태 변경 함수") {
+ val viewModel = LoginViewModel(
+ loginUseCase = mockk(),
+ setAccessTokenUseCase = mockk(relaxed = true),
+ setRefreshTokenUseCase = mockk(relaxed = true),
+ setUserEmailUseCase = mockk(relaxed = true),
+ )
+
+ `when`("setLoadingState를 호출하면") {
+ then("Loading 상태가 된다") {
+ viewModel.setLoadingState()
+ viewModel.uiState.value shouldBe UiState.Loading
+ }
+ }
+
+ `when`("setInitState를 호출하면") {
+ then("Init 상태가 된다") {
+ viewModel.setInitState()
+ viewModel.uiState.value shouldBe UiState.Init
+ }
+ }
+ }
+})
diff --git a/app/src/test/java/com/eatssu/android/presentation/login/UserApiClientBehaviorSpec.kt b/app/src/test/java/com/eatssu/android/presentation/login/UserApiClientBehaviorSpec.kt
new file mode 100644
index 000000000..4dfdbdf6a
--- /dev/null
+++ b/app/src/test/java/com/eatssu/android/presentation/login/UserApiClientBehaviorSpec.kt
@@ -0,0 +1,203 @@
+package com.eatssu.android.presentation.login
+
+import android.content.Context
+import com.eatssu.android.test.AppBehaviorSpec
+import com.kakao.sdk.auth.model.OAuthToken
+import com.kakao.sdk.common.model.ClientError
+import com.kakao.sdk.common.model.ClientErrorCause
+import com.kakao.sdk.user.UserApiClient
+import io.kotest.assertions.throwables.shouldThrow
+import io.kotest.matchers.shouldBe
+import io.mockk.every
+import io.mockk.mockk
+import io.mockk.mockkObject
+import io.mockk.verify
+import kotlinx.coroutines.ExperimentalCoroutinesApi
+import kotlinx.coroutines.test.runTest
+
+@OptIn(ExperimentalCoroutinesApi::class)
+class UserApiClientBehaviorSpec : AppBehaviorSpec({
+
+ given("UserApiClient login 확장 함수") {
+ val context = mockk()
+ val client = mockk()
+
+ mockkObject(UserApiClient.Companion)
+ every { UserApiClient.instance } returns client
+
+ fun stubTalk(result: Pair) {
+ every { client.loginWithKakaoTalk(context, any(), any(), any(), any(), any()) } answers {
+ lastArg<(OAuthToken?, Throwable?) -> Unit>().invoke(result.first, result.second)
+ }
+ }
+
+ fun stubAccount(result: Pair) {
+ every { client.loginWithKakaoAccount(context, any(), any(), any(), any(), any(), any()) } answers {
+ lastArg<(OAuthToken?, Throwable?) -> Unit>().invoke(result.first, result.second)
+ }
+ }
+
+ `when`("카카오톡 로그인이 불가능하면") {
+ val token = mockk()
+ every { client.isKakaoTalkLoginAvailable(context) } returns false
+ stubAccount(token to null)
+
+ then("카카오 계정 로그인 결과를 반환한다") {
+ runTest {
+ UserApiClient.loginWithKakao(context) shouldBe token
+ verify(exactly = 0) { client.loginWithKakaoTalk(context, any(), any(), any(), any(), any()) }
+ }
+ }
+ }
+
+ `when`("카카오톡 로그인 가능 + 카카오톡 로그인이 성공하면") {
+ val token = mockk()
+ every { client.isKakaoTalkLoginAvailable(context) } returns true
+ stubTalk(token to null)
+
+ then("카카오톡 로그인 토큰을 반환한다") {
+ runTest {
+ UserApiClient.loginWithKakao(context) shouldBe token
+ }
+ }
+ }
+
+ `when`("카카오톡 로그인에서 취소 오류가 발생하면") {
+ val cancelled = mockk()
+ every { cancelled.reason } returns ClientErrorCause.Cancelled
+ every { client.isKakaoTalkLoginAvailable(context) } returns true
+ stubTalk(null to cancelled)
+
+ then("카카오 계정 로그인으로 fallback하지 않고 오류를 그대로 던진다") {
+ runTest {
+ shouldThrow {
+ UserApiClient.loginWithKakao(context)
+ }
+
+ verify(exactly = 0) {
+ client.loginWithKakaoAccount(context, any(), any(), any(), any(), any(), any())
+ }
+ }
+ }
+ }
+
+ `when`("카카오톡 로그인에서 일반 오류가 발생하면") {
+ val token = mockk()
+ val error = IllegalStateException("kakao-talk-failed")
+ every { client.isKakaoTalkLoginAvailable(context) } returns true
+ stubTalk(null to error)
+ stubAccount(token to null)
+
+ then("카카오 계정 로그인으로 fallback한다") {
+ runTest {
+ UserApiClient.loginWithKakao(context) shouldBe token
+ verify(exactly = 1) {
+ client.loginWithKakaoAccount(context, any(), any(), any(), any(), any(), any())
+ }
+ }
+ }
+ }
+ }
+
+ given("loginWithKakaoTalk") {
+ val context = mockk()
+ val client = mockk()
+
+ mockkObject(UserApiClient.Companion)
+ every { UserApiClient.instance } returns client
+
+ `when`("callback이 error를 전달하면") {
+ val error = IllegalStateException("talk-error")
+ every { client.loginWithKakaoTalk(context, any(), any(), any(), any(), any()) } answers {
+ lastArg<(OAuthToken?, Throwable?) -> Unit>().invoke(null, error)
+ }
+
+ then("해당 예외를 던진다") {
+ runTest {
+ shouldThrow {
+ UserApiClient.loginWithKakaoTalk(context)
+ }
+ }
+ }
+ }
+
+ `when`("callback이 token을 전달하면") {
+ val token = mockk()
+ every { client.loginWithKakaoTalk(context, any(), any(), any(), any(), any()) } answers {
+ lastArg<(OAuthToken?, Throwable?) -> Unit>().invoke(token, null)
+ }
+
+ then("token을 반환한다") {
+ runTest {
+ UserApiClient.loginWithKakaoTalk(context) shouldBe token
+ }
+ }
+ }
+
+ `when`("callback이 token/error 모두 null을 전달하면") {
+ every { client.loginWithKakaoTalk(context, any(), any(), any(), any(), any()) } answers {
+ lastArg<(OAuthToken?, Throwable?) -> Unit>().invoke(null, null)
+ }
+
+ then("의미 있는 RuntimeException을 던진다") {
+ runTest {
+ val error = shouldThrow {
+ UserApiClient.loginWithKakaoTalk(context)
+ }
+ error.message shouldBe "kakao access token을 받아오는데 실패함, 이유는 명확하지 않음."
+ }
+ }
+ }
+ }
+
+ given("loginWithKakaoAccount") {
+ val context = mockk()
+ val client = mockk()
+
+ mockkObject(UserApiClient.Companion)
+ every { UserApiClient.instance } returns client
+
+ `when`("callback이 error를 전달하면") {
+ val error = IllegalArgumentException("account-error")
+ every { client.loginWithKakaoAccount(context, any(), any(), any(), any(), any(), any()) } answers {
+ lastArg<(OAuthToken?, Throwable?) -> Unit>().invoke(null, error)
+ }
+
+ then("해당 예외를 던진다") {
+ runTest {
+ shouldThrow {
+ UserApiClient.loginWithKakaoAccount(context)
+ }
+ }
+ }
+ }
+
+ `when`("callback이 token을 전달하면") {
+ val token = mockk()
+ every { client.loginWithKakaoAccount(context, any(), any(), any(), any(), any(), any()) } answers {
+ lastArg<(OAuthToken?, Throwable?) -> Unit>().invoke(token, null)
+ }
+
+ then("token을 반환한다") {
+ runTest {
+ UserApiClient.loginWithKakaoAccount(context) shouldBe token
+ }
+ }
+ }
+
+ `when`("callback이 token/error 모두 null을 전달하면") {
+ every { client.loginWithKakaoAccount(context, any(), any(), any(), any(), any(), any()) } answers {
+ lastArg<(OAuthToken?, Throwable?) -> Unit>().invoke(null, null)
+ }
+
+ then("의미 있는 RuntimeException을 던진다") {
+ runTest {
+ val error = shouldThrow {
+ UserApiClient.loginWithKakaoAccount(context)
+ }
+ error.message shouldBe "kakao access token을 받아오는데 실패함, 이유는 명확하지 않음."
+ }
+ }
+ }
+ }
+})
diff --git a/app/src/test/java/com/eatssu/android/presentation/map/MapViewModelBehaviorSpec.kt b/app/src/test/java/com/eatssu/android/presentation/map/MapViewModelBehaviorSpec.kt
new file mode 100644
index 000000000..fc22ea223
--- /dev/null
+++ b/app/src/test/java/com/eatssu/android/presentation/map/MapViewModelBehaviorSpec.kt
@@ -0,0 +1,414 @@
+package com.eatssu.android.presentation.map
+
+import com.eatssu.android.domain.model.College
+import com.eatssu.android.domain.model.Department
+import com.eatssu.android.domain.model.Partnership
+import com.eatssu.android.domain.model.RestaurantType
+import com.eatssu.android.domain.repository.PartnershipRepository
+import com.eatssu.android.domain.usecase.user.GetPartnershipDetailUseCase
+import com.eatssu.android.domain.usecase.user.GetUserCollegeDepartmentUseCase
+import com.eatssu.android.presentation.map.component.FilterType
+import com.eatssu.android.presentation.map.model.PlaceType
+import com.eatssu.android.test.AppBehaviorSpec
+import com.eatssu.android.test.samplePartnership
+import com.eatssu.android.test.samplePartnershipRestaurant
+import com.eatssu.android.test.sampleUserInfo
+import com.eatssu.common.EventLogger
+import com.eatssu.common.UiState
+import io.kotest.assertions.nondeterministic.eventually
+import io.kotest.matchers.shouldBe
+import io.mockk.Runs
+import io.mockk.clearMocks
+import io.mockk.coEvery
+import io.mockk.coVerify
+import io.mockk.every
+import io.mockk.just
+import io.mockk.mockk
+import io.mockk.mockkObject
+import io.mockk.verify
+import kotlinx.coroutines.ExperimentalCoroutinesApi
+import kotlinx.coroutines.delay
+import kotlinx.coroutines.test.runTest
+import kotlin.time.Duration.Companion.seconds
+
+@OptIn(ExperimentalCoroutinesApi::class)
+class MapViewModelBehaviorSpec : AppBehaviorSpec({
+
+ given("제휴 지도 화면") {
+ val partnershipRepository = mockk()
+ val getPartnershipDetailUseCase = mockk()
+ val getUserCollegeDepartmentUseCase = mockk()
+
+ mockkObject(EventLogger)
+ every { EventLogger.clickMap() } just Runs
+ every { EventLogger.clickMapMine(any(), any()) } just Runs
+
+ `when`("학과 정보가 없어서 초기 필터가 전체일 때") {
+ val allPartnerships = listOf(samplePartnership(storeName = "All Cafe"))
+ coEvery {
+ getUserCollegeDepartmentUseCase()
+ } returns sampleUserInfo(
+ nickname = "eatssu",
+ college = College(collegeId = -1, collegeName = "단과대"),
+ department = Department(departmentId = -1, departmentName = "학과"),
+ )
+ coEvery { partnershipRepository.getAllPartnerships() } returns allPartnerships
+ coEvery { partnershipRepository.getUserCollegePartnerships() } returns emptyList()
+
+ val viewModel = MapViewModel(
+ partnershipRepository = partnershipRepository,
+ getPartnershipDetailUseCase = getPartnershipDetailUseCase,
+ getUserCollegeDepartmentUseCase = getUserCollegeDepartmentUseCase,
+ )
+
+ then("All 필터로 시작하고 전체 제휴 목록을 로드한다") {
+ runTest {
+ eventually(2.seconds) {
+ val state = viewModel.uiState.value as UiState.Success
+ state.data.selectedFilter shouldBe FilterType.All
+ state.data.partnerships shouldBe allPartnerships
+ }
+ coVerify(atLeast = 1) { partnershipRepository.getAllPartnerships() }
+ }
+ }
+ }
+
+ `when`("학과 정보가 없는 사용자가 Mine 필터를 선택하면") {
+ coEvery {
+ getUserCollegeDepartmentUseCase()
+ } returns sampleUserInfo(
+ nickname = "eatssu",
+ college = College(collegeId = -1, collegeName = "단과대"),
+ department = Department(departmentId = -1, departmentName = "학과"),
+ )
+ coEvery { partnershipRepository.getAllPartnerships() } returns emptyList()
+ coEvery { partnershipRepository.getUserCollegePartnerships() } returns emptyList()
+
+ val viewModel = MapViewModel(
+ partnershipRepository = partnershipRepository,
+ getPartnershipDetailUseCase = getPartnershipDetailUseCase,
+ getUserCollegeDepartmentUseCase = getUserCollegeDepartmentUseCase,
+ )
+
+ then("RequiresDepartment 결과를 상태에 반영하고 Mine 데이터를 로드하지 않는다") {
+ runTest {
+ eventually(2.seconds) {
+ viewModel.uiState.value shouldBe UiState.Success(MapState(selectedFilter = FilterType.All))
+ }
+
+ clearMocks(partnershipRepository, answers = false, recordedCalls = true)
+ viewModel.setFilter(FilterType.Mine)
+
+ eventually(2.seconds) {
+ val state = viewModel.uiState.value as UiState.Success
+ state.data.filterChangeResult shouldBe MapState.FilterChangeResult.RequiresDepartment
+ }
+ coVerify(exactly = 0) { partnershipRepository.getUserCollegePartnerships() }
+ }
+ }
+ }
+
+ `when`("학과 정보가 있는 사용자가 필터를 변경하면") {
+ val minePartnerships = listOf(samplePartnership(storeName = "Mine Cafe"))
+ val allPartnerships = listOf(samplePartnership(storeName = "All Cafe"))
+ coEvery {
+ getUserCollegeDepartmentUseCase()
+ } returns sampleUserInfo(
+ nickname = "eatssu",
+ college = College(collegeId = 1, collegeName = "IT"),
+ department = Department(departmentId = 11, departmentName = "컴퓨터학부"),
+ )
+ coEvery { partnershipRepository.getUserCollegePartnerships() } returns minePartnerships
+ coEvery { partnershipRepository.getAllPartnerships() } returns allPartnerships
+
+ val viewModel = MapViewModel(
+ partnershipRepository = partnershipRepository,
+ getPartnershipDetailUseCase = getPartnershipDetailUseCase,
+ getUserCollegeDepartmentUseCase = getUserCollegeDepartmentUseCase,
+ )
+
+ then("필터에 맞는 목록을 로드하고 이벤트 로깅을 수행한다") {
+ runTest {
+ eventually(2.seconds) {
+ val initial = viewModel.uiState.value as UiState.Success
+ initial.data.selectedFilter shouldBe FilterType.Mine
+ initial.data.partnerships shouldBe minePartnerships
+ }
+
+ viewModel.setFilter(FilterType.All)
+ eventually(2.seconds) {
+ val allState = viewModel.uiState.value as UiState.Success
+ allState.data.selectedFilter shouldBe FilterType.All
+ allState.data.partnerships shouldBe allPartnerships
+ }
+ verify(atLeast = 1) { EventLogger.clickMap() }
+
+ viewModel.setFilter(FilterType.Mine)
+ eventually(2.seconds) {
+ val mineState = viewModel.uiState.value as UiState.Success
+ mineState.data.selectedFilter shouldBe FilterType.Mine
+ mineState.data.partnerships shouldBe minePartnerships
+ }
+ verify(atLeast = 1) { EventLogger.clickMapMine(1L, 11L) }
+ }
+ }
+ }
+
+ `when`("가게를 선택하면") {
+ val partnershipInfos = listOf(
+ Partnership.PartnershipInfo(
+ id = 1,
+ partnershipType = "DISCOUNT",
+ collegeName = "IT",
+ departmentName = "CS",
+ likeCount = 2,
+ isLiked = true,
+ description = "10% 할인",
+ startDate = "2025-01-01",
+ endDate = "2025-12-31",
+ ),
+ Partnership.PartnershipInfo(
+ id = 2,
+ partnershipType = "DISCOUNT",
+ collegeName = "IT",
+ departmentName = "CS",
+ likeCount = 3,
+ isLiked = false,
+ description = "음료 증정",
+ startDate = "2025-02-01",
+ endDate = "2025-11-30",
+ ),
+ )
+ val partnerships = listOf(
+ samplePartnership(
+ storeName = "Cafe A",
+ infos = partnershipInfos,
+ type = RestaurantType.PUB,
+ )
+ )
+ val representative = samplePartnershipRestaurant(
+ id = 2,
+ type = RestaurantType.PUB,
+ )
+
+ coEvery {
+ getUserCollegeDepartmentUseCase()
+ } returns sampleUserInfo(
+ nickname = "eatssu",
+ college = College(collegeId = 1, collegeName = "IT"),
+ department = Department(departmentId = 11, departmentName = "컴퓨터학부"),
+ )
+ coEvery { partnershipRepository.getUserCollegePartnerships() } returns partnerships
+ coEvery { partnershipRepository.getAllPartnerships() } returns emptyList()
+ every {
+ getPartnershipDetailUseCase(partnerships, "Cafe A", 2)
+ } returns representative
+
+ val viewModel = MapViewModel(
+ partnershipRepository = partnershipRepository,
+ getPartnershipDetailUseCase = getPartnershipDetailUseCase,
+ getUserCollegeDepartmentUseCase = getUserCollegeDepartmentUseCase,
+ )
+
+ then("대표 제휴와 표시용 리스트/장소 타입을 상태에 반영한다") {
+ runTest {
+ eventually(2.seconds) {
+ val state = viewModel.uiState.value as UiState.Success
+ state.data.partnerships shouldBe partnerships
+ }
+
+ viewModel.selectPartnershipByStoreName("Cafe A", 2)
+ eventually(2.seconds) {
+ val state = viewModel.uiState.value as UiState.Success
+ state.data.restaurantPartnershipInfo shouldBe representative
+ state.data.placeType shouldBe PlaceType.PUB
+ state.data.restaurantInfoList.size shouldBe 2
+ state.data.restaurantInfoList[1].period shouldBe "2025-02-01 ~ 2025-11-30"
+ }
+ }
+ }
+ }
+
+ `when`("초기 상태가 Init일 때 Mine 필터를 선택하면") {
+ coEvery {
+ getUserCollegeDepartmentUseCase()
+ } coAnswers {
+ delay(10_000)
+ sampleUserInfo(
+ nickname = "eatssu",
+ college = College(collegeId = -1, collegeName = "단과대"),
+ department = Department(departmentId = -1, departmentName = "학과"),
+ )
+ }
+
+ val viewModel = MapViewModel(
+ partnershipRepository = partnershipRepository,
+ getPartnershipDetailUseCase = getPartnershipDetailUseCase,
+ getUserCollegeDepartmentUseCase = getUserCollegeDepartmentUseCase,
+ )
+
+ then("상태를 변경하지 않고 반환한다") {
+ viewModel.setFilter(FilterType.Mine)
+ viewModel.uiState.value shouldBe UiState.Init
+ coVerify(exactly = 0) { partnershipRepository.getUserCollegePartnerships() }
+ }
+ }
+
+ `when`("제휴 목록에 없는 가게를 선택하면") {
+ val partnerships = listOf(samplePartnership(storeName = "Cafe A"))
+ coEvery {
+ getUserCollegeDepartmentUseCase()
+ } returns sampleUserInfo(
+ nickname = "eatssu",
+ college = College(collegeId = 1, collegeName = "IT"),
+ department = Department(departmentId = 11, departmentName = "컴퓨터학부"),
+ )
+ coEvery { partnershipRepository.getUserCollegePartnerships() } returns partnerships
+ coEvery { partnershipRepository.getAllPartnerships() } returns emptyList()
+
+ val viewModel = MapViewModel(
+ partnershipRepository = partnershipRepository,
+ getPartnershipDetailUseCase = getPartnershipDetailUseCase,
+ getUserCollegeDepartmentUseCase = getUserCollegeDepartmentUseCase,
+ )
+
+ then("선택 상태를 갱신하지 않는다") {
+ runTest {
+ eventually(2.seconds) {
+ (viewModel.uiState.value as UiState.Success).data.partnerships shouldBe partnerships
+ }
+
+ viewModel.selectPartnershipByStoreName("Unknown")
+ eventually(2.seconds) {
+ val state = viewModel.uiState.value as UiState.Success
+ state.data.restaurantPartnershipInfo shouldBe null
+ state.data.restaurantInfoList shouldBe emptyList()
+ }
+ }
+ }
+ }
+
+ `when`("제휴 상세에서 representative를 찾지 못하면") {
+ val partnerships = listOf(
+ samplePartnership(
+ storeName = "Cafe A",
+ infos = listOf(
+ Partnership.PartnershipInfo(
+ id = 1,
+ partnershipType = "DISCOUNT",
+ collegeName = "IT",
+ departmentName = "CS",
+ likeCount = 1,
+ isLiked = true,
+ description = "할인",
+ startDate = "2025-01-01",
+ endDate = "2025-12-31",
+ )
+ ),
+ )
+ )
+ coEvery {
+ getUserCollegeDepartmentUseCase()
+ } returns sampleUserInfo(
+ nickname = "eatssu",
+ college = College(collegeId = 1, collegeName = "IT"),
+ department = Department(departmentId = 11, departmentName = "컴퓨터학부"),
+ )
+ coEvery { partnershipRepository.getUserCollegePartnerships() } returns partnerships
+ coEvery { partnershipRepository.getAllPartnerships() } returns emptyList()
+ every { getPartnershipDetailUseCase(partnerships, "Cafe A", 1) } returns null
+
+ val viewModel = MapViewModel(
+ partnershipRepository = partnershipRepository,
+ getPartnershipDetailUseCase = getPartnershipDetailUseCase,
+ getUserCollegeDepartmentUseCase = getUserCollegeDepartmentUseCase,
+ )
+
+ then("선택 상태를 갱신하지 않는다") {
+ runTest {
+ eventually(2.seconds) {
+ (viewModel.uiState.value as UiState.Success).data.partnerships shouldBe partnerships
+ }
+
+ viewModel.selectPartnershipByStoreName("Cafe A")
+ eventually(2.seconds) {
+ val state = viewModel.uiState.value as UiState.Success
+ state.data.restaurantPartnershipInfo shouldBe null
+ }
+ }
+ }
+ }
+
+ `when`("대표 제휴 타입이 CAFE면") {
+ val partnerships = listOf(samplePartnership(storeName = "Cafe C", type = RestaurantType.CAFE))
+ val representative = samplePartnershipRestaurant(type = RestaurantType.CAFE)
+
+ coEvery {
+ getUserCollegeDepartmentUseCase()
+ } returns sampleUserInfo(
+ nickname = "eatssu",
+ college = College(collegeId = 1, collegeName = "IT"),
+ department = Department(departmentId = 11, departmentName = "컴퓨터학부"),
+ )
+ coEvery { partnershipRepository.getUserCollegePartnerships() } returns partnerships
+ coEvery { partnershipRepository.getAllPartnerships() } returns emptyList()
+ every { getPartnershipDetailUseCase(partnerships, "Cafe C", 1) } returns representative
+
+ val viewModel = MapViewModel(
+ partnershipRepository = partnershipRepository,
+ getPartnershipDetailUseCase = getPartnershipDetailUseCase,
+ getUserCollegeDepartmentUseCase = getUserCollegeDepartmentUseCase,
+ )
+
+ then("PlaceType.CAFE로 변환한다") {
+ runTest {
+ eventually(2.seconds) {
+ (viewModel.uiState.value as UiState.Success).data.partnerships shouldBe partnerships
+ }
+ viewModel.selectPartnershipByStoreName("Cafe C")
+
+ eventually(2.seconds) {
+ val state = viewModel.uiState.value as UiState.Success
+ state.data.placeType shouldBe PlaceType.CAFE
+ }
+ }
+ }
+ }
+
+ `when`("대표 제휴 타입이 RESTAURANT면") {
+ val partnerships = listOf(samplePartnership(storeName = "Restaurant A", type = RestaurantType.RESTAURANT))
+ val representative = samplePartnershipRestaurant(type = RestaurantType.RESTAURANT)
+
+ coEvery {
+ getUserCollegeDepartmentUseCase()
+ } returns sampleUserInfo(
+ nickname = "eatssu",
+ college = College(collegeId = 1, collegeName = "IT"),
+ department = Department(departmentId = 11, departmentName = "컴퓨터학부"),
+ )
+ coEvery { partnershipRepository.getUserCollegePartnerships() } returns partnerships
+ coEvery { partnershipRepository.getAllPartnerships() } returns emptyList()
+ every { getPartnershipDetailUseCase(partnerships, "Restaurant A", 1) } returns representative
+
+ val viewModel = MapViewModel(
+ partnershipRepository = partnershipRepository,
+ getPartnershipDetailUseCase = getPartnershipDetailUseCase,
+ getUserCollegeDepartmentUseCase = getUserCollegeDepartmentUseCase,
+ )
+
+ then("PlaceType.RESTAURANT로 변환한다") {
+ runTest {
+ eventually(2.seconds) {
+ (viewModel.uiState.value as UiState.Success).data.partnerships shouldBe partnerships
+ }
+ viewModel.selectPartnershipByStoreName("Restaurant A")
+
+ eventually(2.seconds) {
+ val state = viewModel.uiState.value as UiState.Success
+ state.data.placeType shouldBe PlaceType.RESTAURANT
+ }
+ }
+ }
+ }
+ }
+})
diff --git a/app/src/test/java/com/eatssu/android/presentation/mypage/MyPageViewModelBehaviorSpec.kt b/app/src/test/java/com/eatssu/android/presentation/mypage/MyPageViewModelBehaviorSpec.kt
new file mode 100644
index 000000000..d2ebca254
--- /dev/null
+++ b/app/src/test/java/com/eatssu/android/presentation/mypage/MyPageViewModelBehaviorSpec.kt
@@ -0,0 +1,150 @@
+package com.eatssu.android.presentation.mypage
+
+import app.cash.turbine.test
+import com.eatssu.android.R
+import com.eatssu.android.data.local.SettingDataStore
+import com.eatssu.android.domain.usecase.alarm.AlarmUseCase
+import com.eatssu.android.domain.usecase.alarm.SetDailyNotificationStatusUseCase
+import com.eatssu.android.domain.usecase.user.GetUserNickNameUseCase
+import com.eatssu.android.test.AppBehaviorSpec
+import com.eatssu.android.test.assertToast
+import com.eatssu.android.test.awaitToastEvent
+import com.eatssu.common.UiState
+import com.eatssu.common.enums.ToastType
+import io.kotest.matchers.shouldBe
+import io.mockk.Runs
+import io.mockk.coEvery
+import io.mockk.coVerify
+import io.mockk.every
+import io.mockk.just
+import io.mockk.mockk
+import io.mockk.verify
+import kotlinx.coroutines.ExperimentalCoroutinesApi
+import kotlinx.coroutines.flow.MutableStateFlow
+import kotlinx.coroutines.test.advanceUntilIdle
+import kotlinx.coroutines.test.runTest
+
+@OptIn(ExperimentalCoroutinesApi::class)
+class MyPageViewModelBehaviorSpec : AppBehaviorSpec({
+
+ given("마이페이지") {
+ val getUserNickNameUseCase = mockk()
+ val setDailyNotificationStatusUseCase = mockk()
+ val alarmUseCase = mockk()
+ val settingDataStore = mockk()
+
+ every { alarmUseCase.scheduleAlarm() } just Runs
+ every { alarmUseCase.cancelAlarm() } just Runs
+ coEvery { setDailyNotificationStatusUseCase(any()) } returns Unit
+
+ `when`("닉네임이 비어 있으면") {
+ val dailyStatus = MutableStateFlow(false)
+ every { settingDataStore.dailyNotificationStatus } returns dailyStatus
+ coEvery { getUserNickNameUseCase() } returns ""
+
+ val viewModel = MyPageViewModel(
+ getUserNickNameUseCase,
+ setDailyNotificationStatusUseCase,
+ alarmUseCase,
+ settingDataStore,
+ )
+
+ then("닉네임을 null로 두고 안내 토스트를 보낸다") {
+ runTest {
+ viewModel.uiState.test {
+ val stateTurbine = this
+ viewModel.uiEvent.test {
+ viewModel.fetchMyInfo()
+ val first = stateTurbine.awaitItem()
+ val state = when (first) {
+ is UiState.Success<*> -> first as UiState.Success
+ else -> stateTurbine.awaitItem() as UiState.Success
+ }
+
+ state.data.nickname shouldBe null
+ awaitToastEvent().assertToast(R.string.toast_require_nickname, ToastType.INFO)
+ cancelAndIgnoreRemainingEvents()
+ }
+ cancelAndIgnoreRemainingEvents()
+ }
+ }
+ }
+ }
+
+ `when`("닉네임이 존재하면") {
+ val dailyStatus = MutableStateFlow(true)
+ every { settingDataStore.dailyNotificationStatus } returns dailyStatus
+ coEvery { getUserNickNameUseCase() } returns "eatssu"
+
+ val viewModel = MyPageViewModel(
+ getUserNickNameUseCase,
+ setDailyNotificationStatusUseCase,
+ alarmUseCase,
+ settingDataStore,
+ )
+
+ then("state에 닉네임과 알림 상태를 반영한다") {
+ runTest {
+ viewModel.uiState.test {
+ viewModel.fetchMyInfo()
+ val first = awaitItem()
+ val state = when (first) {
+ is UiState.Success<*> -> first as UiState.Success
+ else -> awaitItem() as UiState.Success
+ }
+
+ state.data.nickname shouldBe "eatssu"
+ state.data.isAlarmOn shouldBe true
+ cancelAndIgnoreRemainingEvents()
+ }
+ }
+ }
+ }
+
+ `when`("알림을 켜면") {
+ val dailyStatus = MutableStateFlow(false)
+ every { settingDataStore.dailyNotificationStatus } returns dailyStatus
+ coEvery { getUserNickNameUseCase() } returns "eatssu"
+
+ val viewModel = MyPageViewModel(
+ getUserNickNameUseCase,
+ setDailyNotificationStatusUseCase,
+ alarmUseCase,
+ settingDataStore,
+ )
+
+ then("저장 후 알람을 등록한다") {
+ runTest {
+ viewModel.setNotificationOn()
+ advanceUntilIdle()
+
+ coVerify { setDailyNotificationStatusUseCase(true) }
+ verify { alarmUseCase.scheduleAlarm() }
+ }
+ }
+ }
+
+ `when`("알림을 끄면") {
+ val dailyStatus = MutableStateFlow(true)
+ every { settingDataStore.dailyNotificationStatus } returns dailyStatus
+ coEvery { getUserNickNameUseCase() } returns "eatssu"
+
+ val viewModel = MyPageViewModel(
+ getUserNickNameUseCase,
+ setDailyNotificationStatusUseCase,
+ alarmUseCase,
+ settingDataStore,
+ )
+
+ then("저장 후 알람을 해제한다") {
+ runTest {
+ viewModel.setNotificationOff()
+ advanceUntilIdle()
+
+ coVerify { setDailyNotificationStatusUseCase(false) }
+ verify { alarmUseCase.cancelAlarm() }
+ }
+ }
+ }
+ }
+})
diff --git a/app/src/test/java/com/eatssu/android/presentation/mypage/SignOutViewModelBehaviorSpec.kt b/app/src/test/java/com/eatssu/android/presentation/mypage/SignOutViewModelBehaviorSpec.kt
new file mode 100644
index 000000000..26004e4a0
--- /dev/null
+++ b/app/src/test/java/com/eatssu/android/presentation/mypage/SignOutViewModelBehaviorSpec.kt
@@ -0,0 +1,66 @@
+package com.eatssu.android.presentation.mypage
+
+import app.cash.turbine.test
+import com.eatssu.android.R
+import com.eatssu.android.domain.usecase.auth.LogoutUseCase
+import com.eatssu.android.domain.usecase.auth.SignOutUseCase
+import com.eatssu.android.test.AppBehaviorSpec
+import com.eatssu.android.test.assertToast
+import com.eatssu.android.test.awaitToastEvent
+import com.eatssu.common.UiState
+import com.eatssu.common.enums.ToastType
+import io.kotest.matchers.shouldBe
+import io.mockk.coEvery
+import io.mockk.coVerify
+import io.mockk.mockk
+import kotlinx.coroutines.ExperimentalCoroutinesApi
+import kotlinx.coroutines.test.advanceUntilIdle
+import kotlinx.coroutines.test.runTest
+
+@OptIn(ExperimentalCoroutinesApi::class)
+class SignOutViewModelBehaviorSpec : AppBehaviorSpec({
+
+ given("회원탈퇴") {
+ val logoutUseCase = mockk()
+ val signOutUseCase = mockk()
+
+ `when`("회원탈퇴 API가 실패하면") {
+ coEvery { signOutUseCase() } returns false
+ val viewModel = SignOutViewModel(logoutUseCase, signOutUseCase)
+
+ then("Error 상태와 실패 토스트를 보낸다") {
+ runTest {
+ viewModel.uiEvent.test {
+ viewModel.signOut()
+ advanceUntilIdle()
+
+ viewModel.uiState.value shouldBe UiState.Error
+ awaitToastEvent().assertToast(R.string.toast_sign_out_fail, ToastType.ERROR)
+ coVerify(exactly = 0) { logoutUseCase() }
+ cancelAndIgnoreRemainingEvents()
+ }
+ }
+ }
+ }
+
+ `when`("회원탈퇴 API가 성공하면") {
+ coEvery { signOutUseCase() } returns true
+ coEvery { logoutUseCase() } returns Unit
+ val viewModel = SignOutViewModel(logoutUseCase, signOutUseCase)
+
+ then("성공 상태와 성공 토스트를 보낸 후 로그아웃을 수행한다") {
+ runTest {
+ viewModel.uiEvent.test {
+ viewModel.signOut()
+ advanceUntilIdle()
+
+ viewModel.uiState.value shouldBe UiState.Success(SignOutState(isSignOuted = true))
+ awaitToastEvent().assertToast(R.string.toast_sign_out_success, ToastType.SUCCESS)
+ coVerify(exactly = 1) { logoutUseCase() }
+ cancelAndIgnoreRemainingEvents()
+ }
+ }
+ }
+ }
+ }
+})
diff --git a/app/src/test/java/com/eatssu/android/presentation/mypage/language/LanguageSelectorViewModelBehaviorSpec.kt b/app/src/test/java/com/eatssu/android/presentation/mypage/language/LanguageSelectorViewModelBehaviorSpec.kt
new file mode 100644
index 000000000..f4e3478dd
--- /dev/null
+++ b/app/src/test/java/com/eatssu/android/presentation/mypage/language/LanguageSelectorViewModelBehaviorSpec.kt
@@ -0,0 +1,60 @@
+package com.eatssu.android.presentation.mypage.language
+
+import androidx.appcompat.app.AppCompatDelegate
+import androidx.core.os.LocaleListCompat
+import com.eatssu.android.data.local.SettingDataStore
+import com.eatssu.android.test.AppBehaviorSpec
+import com.eatssu.common.enums.AppLanguage
+import io.kotest.matchers.shouldBe
+import io.mockk.coEvery
+import io.mockk.coVerify
+import io.mockk.every
+import io.mockk.just
+import io.mockk.mockk
+import io.mockk.mockkStatic
+import io.mockk.runs
+import io.mockk.verify
+import kotlinx.coroutines.ExperimentalCoroutinesApi
+import kotlinx.coroutines.flow.MutableStateFlow
+import kotlinx.coroutines.test.advanceUntilIdle
+import kotlinx.coroutines.test.runTest
+
+@OptIn(ExperimentalCoroutinesApi::class)
+class LanguageSelectorViewModelBehaviorSpec : AppBehaviorSpec({
+
+ given("언어 선택") {
+ val settingDataStore = mockk()
+ val languageFlow = MutableStateFlow(AppLanguage.KOREAN)
+ every { settingDataStore.appLanguage } returns languageFlow
+ coEvery { settingDataStore.setAppLanguage(any()) } returns Unit
+
+ mockkStatic(AppCompatDelegate::class)
+ every { AppCompatDelegate.setApplicationLocales(any()) } just runs
+
+ `when`("초기화되면") {
+ val viewModel = LanguageSelectorViewModel(settingDataStore)
+
+ then("DataStore 언어를 selectedLanguage에 반영한다") {
+ runTest {
+ advanceUntilIdle()
+ viewModel.selectedLanguage.value shouldBe AppLanguage.KOREAN
+ }
+ }
+ }
+
+ `when`("언어를 선택하면") {
+ val viewModel = LanguageSelectorViewModel(settingDataStore)
+
+ then("DataStore 저장과 AppCompat locale 적용을 수행한다") {
+ runTest {
+ viewModel.selectLanguage(AppLanguage.KOREAN)
+ advanceUntilIdle()
+
+ coVerify { settingDataStore.setAppLanguage(AppLanguage.KOREAN) }
+ verify { AppCompatDelegate.setApplicationLocales(LocaleListCompat.forLanguageTags(AppLanguage.KOREAN.code)) }
+ viewModel.selectedLanguage.value shouldBe AppLanguage.KOREAN
+ }
+ }
+ }
+ }
+})
diff --git a/app/src/test/java/com/eatssu/android/presentation/mypage/myreview/MyReviewViewModelBehaviorSpec.kt b/app/src/test/java/com/eatssu/android/presentation/mypage/myreview/MyReviewViewModelBehaviorSpec.kt
new file mode 100644
index 000000000..d3feb67ac
--- /dev/null
+++ b/app/src/test/java/com/eatssu/android/presentation/mypage/myreview/MyReviewViewModelBehaviorSpec.kt
@@ -0,0 +1,107 @@
+package com.eatssu.android.presentation.mypage.myreview
+
+import app.cash.turbine.test
+import com.eatssu.android.R
+import com.eatssu.android.domain.usecase.review.DeleteReviewUseCase
+import com.eatssu.android.domain.usecase.review.GetMyReviewsUseCase
+import com.eatssu.android.domain.usecase.user.GetUserNickNameUseCase
+import com.eatssu.android.test.AppBehaviorSpec
+import com.eatssu.android.test.assertToast
+import com.eatssu.android.test.awaitToastEvent
+import com.eatssu.android.test.sampleReview
+import com.eatssu.common.UiState
+import com.eatssu.common.enums.ToastType
+import io.kotest.matchers.shouldBe
+import io.mockk.coEvery
+import io.mockk.coVerify
+import io.mockk.mockk
+import kotlinx.coroutines.ExperimentalCoroutinesApi
+import kotlinx.coroutines.test.advanceUntilIdle
+import kotlinx.coroutines.test.runTest
+
+@OptIn(ExperimentalCoroutinesApi::class)
+class MyReviewViewModelBehaviorSpec : AppBehaviorSpec({
+
+ given("내 리뷰 화면") {
+ val getMyReviewsUseCase = mockk()
+ val getUserNickNameUseCase = mockk()
+ val deleteReviewUseCase = mockk()
+
+ `when`("리뷰 목록이 비어있으면") {
+ coEvery { getMyReviewsUseCase() } returns emptyList()
+ val viewModel = MyReviewViewModel(getMyReviewsUseCase, getUserNickNameUseCase, deleteReviewUseCase)
+
+ then("NoReview 상태가 된다") {
+ runTest {
+ advanceUntilIdle()
+ viewModel.uiState.value shouldBe UiState.Success(MyReviewState.NoReview)
+ }
+ }
+ }
+
+ `when`("리뷰 목록이 있으면") {
+ val review = sampleReview()
+ coEvery { getMyReviewsUseCase() } returns listOf(review)
+ val viewModel = MyReviewViewModel(getMyReviewsUseCase, getUserNickNameUseCase, deleteReviewUseCase)
+
+ then("ReviewExists 상태가 된다") {
+ runTest {
+ advanceUntilIdle()
+ viewModel.uiState.value shouldBe UiState.Success(MyReviewState.ReviewExists(listOf(review)))
+ }
+ }
+ }
+
+ `when`("닉네임 로드를 호출하면") {
+ coEvery { getMyReviewsUseCase() } returns emptyList()
+ coEvery { getUserNickNameUseCase() } returns "nickname"
+ val viewModel = MyReviewViewModel(getMyReviewsUseCase, getUserNickNameUseCase, deleteReviewUseCase)
+
+ then("닉네임 stateFlow를 업데이트한다") {
+ runTest {
+ viewModel.loadUserNickname()
+ advanceUntilIdle()
+ viewModel.nickname.value shouldBe "nickname"
+ }
+ }
+ }
+
+ `when`("리뷰 삭제가 실패하면") {
+ coEvery { getMyReviewsUseCase() } returns emptyList()
+ coEvery { deleteReviewUseCase(10L) } returns false
+ val viewModel = MyReviewViewModel(getMyReviewsUseCase, getUserNickNameUseCase, deleteReviewUseCase)
+
+ then("실패 토스트를 보낸다") {
+ runTest {
+ viewModel.uiEvent.test {
+ viewModel.deleteReview(10L)
+ advanceUntilIdle()
+
+ awaitToastEvent().assertToast(R.string.toast_review_delete_failed, ToastType.ERROR)
+ cancelAndIgnoreRemainingEvents()
+ }
+ }
+ }
+ }
+
+ `when`("리뷰 삭제가 성공하면") {
+ val review = sampleReview(id = 2L)
+ coEvery { getMyReviewsUseCase() } returnsMany listOf(listOf(review), emptyList())
+ coEvery { deleteReviewUseCase(2L) } returns true
+ val viewModel = MyReviewViewModel(getMyReviewsUseCase, getUserNickNameUseCase, deleteReviewUseCase)
+
+ then("성공 토스트 후 목록을 재조회한다") {
+ runTest {
+ viewModel.uiEvent.test {
+ viewModel.deleteReview(2L)
+ advanceUntilIdle()
+
+ awaitToastEvent().assertToast(R.string.toast_review_delete_success, ToastType.SUCCESS)
+ coVerify(atLeast = 2) { getMyReviewsUseCase() }
+ cancelAndIgnoreRemainingEvents()
+ }
+ }
+ }
+ }
+ }
+})
diff --git a/app/src/test/java/com/eatssu/android/presentation/mypage/userinfo/UserInfoViewModelBehaviorSpec.kt b/app/src/test/java/com/eatssu/android/presentation/mypage/userinfo/UserInfoViewModelBehaviorSpec.kt
new file mode 100644
index 000000000..3903541f3
--- /dev/null
+++ b/app/src/test/java/com/eatssu/android/presentation/mypage/userinfo/UserInfoViewModelBehaviorSpec.kt
@@ -0,0 +1,719 @@
+package com.eatssu.android.presentation.mypage.userinfo
+
+import app.cash.turbine.test
+import com.eatssu.android.R
+import com.eatssu.android.domain.model.College
+import com.eatssu.android.domain.model.Department
+import com.eatssu.android.domain.repository.UserRepository
+import com.eatssu.android.domain.usecase.user.GetUserCollegeDepartmentUseCase
+import com.eatssu.android.domain.usecase.user.NicknameValidationResult
+import com.eatssu.android.domain.usecase.user.SetUserCollegeDepartmentUseCase
+import com.eatssu.android.domain.usecase.user.SetUserNicknameUseCase
+import com.eatssu.android.domain.usecase.user.ValidateNicknameLocalUseCase
+import com.eatssu.android.domain.usecase.user.ValidateNicknameServerUseCase
+import com.eatssu.android.test.AppBehaviorSpec
+import com.eatssu.android.test.asStringResIdOrNull
+import com.eatssu.android.test.expectToast
+import com.eatssu.android.test.sampleUserInfo
+import com.eatssu.common.UiState
+import com.eatssu.common.UiText
+import com.eatssu.common.enums.ToastType
+import io.kotest.assertions.nondeterministic.eventually
+import io.kotest.matchers.shouldBe
+import io.mockk.coEvery
+import io.mockk.coVerify
+import io.mockk.every
+import io.mockk.mockk
+import kotlinx.coroutines.ExperimentalCoroutinesApi
+import kotlinx.coroutines.test.advanceUntilIdle
+import kotlinx.coroutines.test.runTest
+import kotlin.time.Duration.Companion.seconds
+
+@OptIn(ExperimentalCoroutinesApi::class)
+class UserInfoViewModelBehaviorSpec : AppBehaviorSpec({
+
+ given("유저 정보 수정 화면") {
+ val baseCollege = College(collegeId = 1, collegeName = "IT")
+ val baseDepartment = Department(departmentId = 11, departmentName = "컴퓨터학부")
+ val otherCollege = College(collegeId = 2, collegeName = "경영")
+ val otherDepartment = Department(departmentId = 21, departmentName = "경영학과")
+
+ `when`("초기화되면") {
+ val setUserNicknameUseCase = mockk()
+ val getUserCollegeDepartmentUseCase = mockk()
+ val setUserCollegeDepartmentUseCase = mockk()
+ val validateNicknameServerUseCase = mockk()
+ val validateNicknameLocalUseCase = mockk()
+ val userRepository = mockk()
+
+ coEvery {
+ getUserCollegeDepartmentUseCase()
+ } returns sampleUserInfo(
+ nickname = "oldNick",
+ college = baseCollege,
+ department = baseDepartment,
+ )
+ coEvery { userRepository.getTotalColleges() } returns listOf(baseCollege, otherCollege)
+ coEvery { userRepository.getTotalDepartments(baseCollege.collegeId) } returns listOf(baseDepartment)
+ every { validateNicknameLocalUseCase(any(), any(), any()) } returns NicknameValidationResult.Valid
+
+ val viewModel = UserInfoViewModel(
+ setUserNicknameUseCase = setUserNicknameUseCase,
+ getUserCollegeDepartmentUseCase = getUserCollegeDepartmentUseCase,
+ setUserCollegeDepartmentUseCase = setUserCollegeDepartmentUseCase,
+ validateNicknameServerUseCase = validateNicknameServerUseCase,
+ validateNicknameLocalUseCase = validateNicknameLocalUseCase,
+ userRepository = userRepository,
+ )
+
+ then("닉네임/단과대/학과와 목록을 로드한 Success 상태가 된다") {
+ runTest {
+ eventually(2.seconds) {
+ val state = viewModel.uiState.value as UiState.Success
+ state.data.nickname shouldBe "oldNick"
+ state.data.selectedCollege shouldBe baseCollege
+ state.data.selectedDepartment shouldBe baseDepartment
+ state.data.collegeList.size shouldBe 2
+ state.data.departmentList shouldBe listOf(baseDepartment)
+ }
+ }
+ }
+ }
+
+ `when`("닉네임을 변경하고 로컬 검증에 실패하면") {
+ val setUserNicknameUseCase = mockk()
+ val getUserCollegeDepartmentUseCase = mockk()
+ val setUserCollegeDepartmentUseCase = mockk()
+ val validateNicknameServerUseCase = mockk()
+ val validateNicknameLocalUseCase = mockk()
+ val userRepository = mockk()
+
+ coEvery {
+ getUserCollegeDepartmentUseCase()
+ } returns sampleUserInfo(
+ nickname = "oldNick",
+ college = baseCollege,
+ department = baseDepartment,
+ )
+ coEvery { userRepository.getTotalColleges() } returns listOf(baseCollege)
+ coEvery { userRepository.getTotalDepartments(baseCollege.collegeId) } returns listOf(baseDepartment)
+ every {
+ validateNicknameLocalUseCase("x", UserInfoViewModel.MIN_NICKNAME_LENGTH, UserInfoViewModel.MAX_NICKNAME_LENGTH)
+ } returns NicknameValidationResult.Invalid(UiText.StringResource(R.string.nickname_error_length))
+
+ val viewModel = UserInfoViewModel(
+ setUserNicknameUseCase = setUserNicknameUseCase,
+ getUserCollegeDepartmentUseCase = getUserCollegeDepartmentUseCase,
+ setUserCollegeDepartmentUseCase = setUserCollegeDepartmentUseCase,
+ validateNicknameServerUseCase = validateNicknameServerUseCase,
+ validateNicknameLocalUseCase = validateNicknameLocalUseCase,
+ userRepository = userRepository,
+ )
+
+ then("검증 에러를 표시하고 중복확인 상태를 초기화한다") {
+ runTest {
+ eventually(2.seconds) {
+ (viewModel.uiState.value is UiState.Success) shouldBe true
+ }
+
+ viewModel.onNicknameChanged("x")
+ val state = viewModel.uiState.value as UiState.Success
+
+ state.data.nickname shouldBe "x"
+ state.data.isNicknameChanged shouldBe true
+ state.data.isDuplicationChecked shouldBe false
+ state.data.nicknameValidationError.asStringResIdOrNull() shouldBe R.string.nickname_error_length
+ }
+ }
+ }
+
+ `when`("닉네임 중복확인 서버 호출이 실패하면") {
+ val setUserNicknameUseCase = mockk()
+ val getUserCollegeDepartmentUseCase = mockk()
+ val setUserCollegeDepartmentUseCase = mockk()
+ val validateNicknameServerUseCase = mockk()
+ val validateNicknameLocalUseCase = mockk()
+ val userRepository = mockk()
+
+ coEvery {
+ getUserCollegeDepartmentUseCase()
+ } returns sampleUserInfo(
+ nickname = "oldNick",
+ college = baseCollege,
+ department = baseDepartment,
+ )
+ coEvery { userRepository.getTotalColleges() } returns listOf(baseCollege)
+ coEvery { userRepository.getTotalDepartments(baseCollege.collegeId) } returns listOf(baseDepartment)
+ every { validateNicknameLocalUseCase(any(), any(), any()) } returns NicknameValidationResult.Valid
+ coEvery { validateNicknameServerUseCase("newNick") } returns Result.failure(IllegalArgumentException("dup"))
+
+ val viewModel = UserInfoViewModel(
+ setUserNicknameUseCase = setUserNicknameUseCase,
+ getUserCollegeDepartmentUseCase = getUserCollegeDepartmentUseCase,
+ setUserCollegeDepartmentUseCase = setUserCollegeDepartmentUseCase,
+ validateNicknameServerUseCase = validateNicknameServerUseCase,
+ validateNicknameLocalUseCase = validateNicknameLocalUseCase,
+ userRepository = userRepository,
+ )
+
+ then("서버 에러 메시지를 validationError에 반영한다") {
+ runTest {
+ eventually(2.seconds) {
+ (viewModel.uiState.value is UiState.Success) shouldBe true
+ }
+
+ viewModel.onNicknameChanged("newNick")
+ viewModel.checkNicknameDuplication()
+
+ eventually(2.seconds) {
+ val state = viewModel.uiState.value as UiState.Success
+ (state.data.nicknameValidationError as UiText.DynamicString).value shouldBe "dup"
+ state.data.isDuplicationChecked shouldBe false
+ }
+ }
+ }
+ }
+
+ `when`("변경사항 없이 저장하면") {
+ val setUserNicknameUseCase = mockk()
+ val getUserCollegeDepartmentUseCase = mockk()
+ val setUserCollegeDepartmentUseCase = mockk()
+ val validateNicknameServerUseCase = mockk()
+ val validateNicknameLocalUseCase = mockk()
+ val userRepository = mockk()
+
+ coEvery {
+ getUserCollegeDepartmentUseCase()
+ } returns sampleUserInfo(
+ nickname = "oldNick",
+ college = baseCollege,
+ department = baseDepartment,
+ )
+ coEvery { userRepository.getTotalColleges() } returns listOf(baseCollege)
+ coEvery { userRepository.getTotalDepartments(baseCollege.collegeId) } returns listOf(baseDepartment)
+ every { validateNicknameLocalUseCase(any(), any(), any()) } returns NicknameValidationResult.Valid
+
+ val viewModel = UserInfoViewModel(
+ setUserNicknameUseCase = setUserNicknameUseCase,
+ getUserCollegeDepartmentUseCase = getUserCollegeDepartmentUseCase,
+ setUserCollegeDepartmentUseCase = setUserCollegeDepartmentUseCase,
+ validateNicknameServerUseCase = validateNicknameServerUseCase,
+ validateNicknameLocalUseCase = validateNicknameLocalUseCase,
+ userRepository = userRepository,
+ )
+
+ then("no changes 토스트를 보내고 완료 플래그를 true로 만든다") {
+ runTest {
+ eventually(2.seconds) {
+ (viewModel.uiState.value is UiState.Success) shouldBe true
+ }
+
+ viewModel.uiEvent.test {
+ viewModel.saveUserInfo()
+ expectToast(R.string.toast_no_changes, ToastType.INFO)
+
+ eventually(2.seconds) {
+ val state = viewModel.uiState.value as UiState.Success
+ state.data.isDone shouldBe true
+ }
+ cancelAndIgnoreRemainingEvents()
+ }
+ }
+ }
+ }
+
+ `when`("닉네임 변경 저장이 실패하면") {
+ val setUserNicknameUseCase = mockk()
+ val getUserCollegeDepartmentUseCase = mockk()
+ val setUserCollegeDepartmentUseCase = mockk()
+ val validateNicknameServerUseCase = mockk()
+ val validateNicknameLocalUseCase = mockk()
+ val userRepository = mockk()
+
+ coEvery {
+ getUserCollegeDepartmentUseCase()
+ } returns sampleUserInfo(
+ nickname = "oldNick",
+ college = baseCollege,
+ department = baseDepartment,
+ )
+ coEvery { userRepository.getTotalColleges() } returns listOf(baseCollege)
+ coEvery { userRepository.getTotalDepartments(baseCollege.collegeId) } returns listOf(baseDepartment)
+ every { validateNicknameLocalUseCase(any(), any(), any()) } returns NicknameValidationResult.Valid
+ coEvery { setUserNicknameUseCase("newNick") } returns Result.failure(IllegalStateException("fail"))
+
+ val viewModel = UserInfoViewModel(
+ setUserNicknameUseCase = setUserNicknameUseCase,
+ getUserCollegeDepartmentUseCase = getUserCollegeDepartmentUseCase,
+ setUserCollegeDepartmentUseCase = setUserCollegeDepartmentUseCase,
+ validateNicknameServerUseCase = validateNicknameServerUseCase,
+ validateNicknameLocalUseCase = validateNicknameLocalUseCase,
+ userRepository = userRepository,
+ )
+
+ then("실패 토스트를 보내고 Error 상태가 된다") {
+ runTest {
+ eventually(2.seconds) {
+ (viewModel.uiState.value is UiState.Success) shouldBe true
+ }
+
+ viewModel.onNicknameChanged("newNick")
+ viewModel.uiEvent.test {
+ viewModel.saveUserInfo()
+ expectToast(R.string.toast_nickname_change_failed, ToastType.ERROR)
+ eventually(2.seconds) {
+ viewModel.uiState.value shouldBe UiState.Error
+ }
+ cancelAndIgnoreRemainingEvents()
+ }
+ }
+ }
+ }
+
+ `when`("닉네임과 학과를 모두 바꿔 저장하면") {
+ val setUserNicknameUseCase = mockk()
+ val getUserCollegeDepartmentUseCase = mockk()
+ val setUserCollegeDepartmentUseCase = mockk()
+ val validateNicknameServerUseCase = mockk()
+ val validateNicknameLocalUseCase = mockk()
+ val userRepository = mockk()
+
+ coEvery {
+ getUserCollegeDepartmentUseCase()
+ } returns sampleUserInfo(
+ nickname = "oldNick",
+ college = baseCollege,
+ department = baseDepartment,
+ )
+ coEvery { userRepository.getTotalColleges() } returns listOf(baseCollege, otherCollege)
+ coEvery { userRepository.getTotalDepartments(any()) } answers {
+ when (firstArg()) {
+ baseCollege.collegeId -> listOf(baseDepartment)
+ otherCollege.collegeId -> listOf(otherDepartment)
+ else -> emptyList()
+ }
+ }
+ every { validateNicknameLocalUseCase(any(), any(), any()) } returns NicknameValidationResult.Valid
+ coEvery { validateNicknameServerUseCase("newNick") } returns Result.success(Unit)
+ coEvery { setUserNicknameUseCase("newNick") } returns Result.success(Unit)
+ coEvery { userRepository.setUserDepartment(otherDepartment.departmentId) } returns true
+ coEvery { setUserCollegeDepartmentUseCase(otherCollege, otherDepartment) } returns Unit
+
+ val viewModel = UserInfoViewModel(
+ setUserNicknameUseCase = setUserNicknameUseCase,
+ getUserCollegeDepartmentUseCase = getUserCollegeDepartmentUseCase,
+ setUserCollegeDepartmentUseCase = setUserCollegeDepartmentUseCase,
+ validateNicknameServerUseCase = validateNicknameServerUseCase,
+ validateNicknameLocalUseCase = validateNicknameLocalUseCase,
+ userRepository = userRepository,
+ )
+
+ then("통합 수정 성공 토스트를 보내고 완료 플래그를 true로 만든다") {
+ runTest {
+ eventually(2.seconds) {
+ (viewModel.uiState.value is UiState.Success) shouldBe true
+ }
+
+ viewModel.onNicknameChanged("newNick")
+ viewModel.checkNicknameDuplication()
+ viewModel.selectCollege(otherCollege)
+ eventually(2.seconds) {
+ val state = viewModel.uiState.value as UiState.Success
+ state.data.departmentList shouldBe listOf(otherDepartment)
+ }
+ viewModel.selectDepartment(otherDepartment)
+
+ viewModel.uiEvent.test {
+ viewModel.saveUserInfo()
+ expectToast(R.string.toast_info_updated, ToastType.INFO)
+
+ eventually(2.seconds) {
+ val state = viewModel.uiState.value as UiState.Success
+ state.data.isDone shouldBe true
+ }
+ coVerify { setUserCollegeDepartmentUseCase(otherCollege, otherDepartment) }
+ cancelAndIgnoreRemainingEvents()
+ }
+ }
+ }
+ }
+
+ `when`("닉네임 중복확인이 성공하면") {
+ val setUserNicknameUseCase = mockk()
+ val getUserCollegeDepartmentUseCase = mockk()
+ val setUserCollegeDepartmentUseCase = mockk()
+ val validateNicknameServerUseCase = mockk()
+ val validateNicknameLocalUseCase = mockk()
+ val userRepository = mockk()
+
+ coEvery {
+ getUserCollegeDepartmentUseCase()
+ } returns sampleUserInfo(
+ nickname = "oldNick",
+ college = baseCollege,
+ department = baseDepartment,
+ )
+ coEvery { userRepository.getTotalColleges() } returns listOf(baseCollege)
+ coEvery { userRepository.getTotalDepartments(baseCollege.collegeId) } returns listOf(baseDepartment)
+ every { validateNicknameLocalUseCase(any(), any(), any()) } returns NicknameValidationResult.Valid
+ coEvery { validateNicknameServerUseCase("newNick") } returns Result.success(Unit)
+
+ val viewModel = UserInfoViewModel(
+ setUserNicknameUseCase = setUserNicknameUseCase,
+ getUserCollegeDepartmentUseCase = getUserCollegeDepartmentUseCase,
+ setUserCollegeDepartmentUseCase = setUserCollegeDepartmentUseCase,
+ validateNicknameServerUseCase = validateNicknameServerUseCase,
+ validateNicknameLocalUseCase = validateNicknameLocalUseCase,
+ userRepository = userRepository,
+ )
+
+ then("중복확인 완료 상태가 true가 된다") {
+ runTest {
+ eventually(2.seconds) {
+ (viewModel.uiState.value is UiState.Success) shouldBe true
+ }
+
+ viewModel.onNicknameChanged("newNick")
+ viewModel.checkNicknameDuplication()
+
+ eventually(2.seconds) {
+ val state = viewModel.uiState.value as UiState.Success
+ state.data.isDuplicationChecked shouldBe true
+ state.data.nicknameValidationError shouldBe null
+ }
+ }
+ }
+ }
+
+ `when`("닉네임 중복확인 실패 메시지가 null이면") {
+ val setUserNicknameUseCase = mockk()
+ val getUserCollegeDepartmentUseCase = mockk()
+ val setUserCollegeDepartmentUseCase = mockk()
+ val validateNicknameServerUseCase = mockk()
+ val validateNicknameLocalUseCase = mockk