Skip to content

Commit 3b674a6

Browse files
committed
Test: RoutineRepositoryImpl 단위 테스트 추가
1 parent ad48939 commit 3b674a6

3 files changed

Lines changed: 263 additions & 0 deletions

File tree

Lines changed: 18 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,18 @@
1+
package com.threegap.bitnagil.di.data
2+
3+
import com.threegap.bitnagil.data.di.IoDispatcher
4+
import dagger.Module
5+
import dagger.Provides
6+
import dagger.hilt.InstallIn
7+
import dagger.hilt.components.SingletonComponent
8+
import kotlinx.coroutines.CoroutineDispatcher
9+
import kotlinx.coroutines.Dispatchers
10+
11+
@Module
12+
@InstallIn(SingletonComponent::class)
13+
object CoroutineModule {
14+
15+
@Provides
16+
@IoDispatcher
17+
fun provideIoDispatcher(): CoroutineDispatcher = Dispatchers.IO
18+
}
Lines changed: 7 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,7 @@
1+
package com.threegap.bitnagil.data.di
2+
3+
import javax.inject.Qualifier
4+
5+
@Qualifier
6+
@Retention(AnnotationRetention.BINARY)
7+
annotation class IoDispatcher
Lines changed: 238 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,238 @@
1+
package com.threegap.bitnagil.data.routine.repositoryImpl
2+
3+
import com.threegap.bitnagil.data.routine.datasource.RoutineRemoteDataSource
4+
import com.threegap.bitnagil.data.routine.datasourceImpl.RoutineLocalDataSourceImpl
5+
import com.threegap.bitnagil.data.routine.model.request.RoutineCompletionRequest
6+
import com.threegap.bitnagil.data.routine.model.request.RoutineEditRequest
7+
import com.threegap.bitnagil.data.routine.model.request.RoutineRegisterRequest
8+
import com.threegap.bitnagil.data.routine.model.response.RoutineScheduleResponse
9+
import com.threegap.bitnagil.domain.routine.model.DailyRoutines
10+
import com.threegap.bitnagil.domain.routine.model.DayOfWeek
11+
import com.threegap.bitnagil.domain.routine.model.Routine
12+
import com.threegap.bitnagil.domain.routine.model.RoutineCompletionInfo
13+
import com.threegap.bitnagil.domain.routine.model.RoutineSchedule
14+
import com.threegap.bitnagil.domain.routine.repository.RoutineRepository
15+
import kotlinx.coroutines.ExperimentalCoroutinesApi
16+
import kotlinx.coroutines.flow.first
17+
import kotlinx.coroutines.test.StandardTestDispatcher
18+
import kotlinx.coroutines.test.advanceTimeBy
19+
import kotlinx.coroutines.test.runCurrent
20+
import kotlinx.coroutines.test.runTest
21+
import org.junit.Assert.assertEquals
22+
import org.junit.Assert.assertTrue
23+
import org.junit.Before
24+
import org.junit.Test
25+
import java.util.concurrent.atomic.AtomicInteger
26+
27+
@OptIn(ExperimentalCoroutinesApi::class)
28+
class RoutineRepositoryImplTest {
29+
30+
private lateinit var localDataSource: RoutineLocalDataSourceImpl
31+
private lateinit var remoteDataSource: FakeRoutineRemoteDataSource
32+
private lateinit var repository: RoutineRepository
33+
34+
@Before
35+
fun setup() {
36+
localDataSource = RoutineLocalDataSourceImpl()
37+
remoteDataSource = FakeRoutineRemoteDataSource()
38+
}
39+
40+
private fun createRepository(testScheduler: kotlinx.coroutines.test.TestCoroutineScheduler): RoutineRepository {
41+
val dispatcher = StandardTestDispatcher(testScheduler)
42+
return RoutineRepositoryImpl(remoteDataSource, localDataSource, dispatcher)
43+
}
44+
45+
// --- observeWeeklyRoutines ---
46+
47+
@Test
48+
fun `캐시 미스 시 Remote에서 fetch 후 결과를 방출해야 한다`() = runTest {
49+
repository = createRepository(testScheduler)
50+
remoteDataSource.scheduleResponse = Result.success(RoutineScheduleResponse(emptyMap()))
51+
52+
val result = repository.observeWeeklyRoutines("2024-01-01", "2024-01-07").first()
53+
54+
assertEquals(RoutineSchedule(emptyMap()), result)
55+
assertEquals(1, remoteDataSource.fetchCount.get())
56+
}
57+
58+
@Test
59+
fun `동일 주차 재구독 시 Remote를 재호출하지 않고 캐시를 반환해야 한다`() = runTest {
60+
repository = createRepository(testScheduler)
61+
remoteDataSource.scheduleResponse = Result.success(RoutineScheduleResponse(emptyMap()))
62+
val startDate = "2024-01-01"
63+
val endDate = "2024-01-07"
64+
65+
repository.observeWeeklyRoutines(startDate, endDate).first()
66+
repository.observeWeeklyRoutines(startDate, endDate).first()
67+
68+
assertEquals(1, remoteDataSource.fetchCount.get())
69+
}
70+
71+
@Test
72+
fun `다른 주차 구독 시 캐시 초기화 후 Remote를 재호출해야 한다`() = runTest {
73+
repository = createRepository(testScheduler)
74+
remoteDataSource.scheduleResponse = Result.success(RoutineScheduleResponse(emptyMap()))
75+
76+
repository.observeWeeklyRoutines("2024-01-01", "2024-01-07").first()
77+
repository.observeWeeklyRoutines("2024-01-08", "2024-01-14").first()
78+
79+
assertEquals(2, remoteDataSource.fetchCount.get())
80+
}
81+
82+
// --- applyRoutineToggle ---
83+
84+
@Test
85+
fun `토글 호출 시 로컬 캐시에 즉시 optimistic update가 반영되어야 한다`() = runTest {
86+
repository = createRepository(testScheduler)
87+
val (dateKey, routineId) = setupCacheWithRoutine(isCompleted = false)
88+
89+
repository.applyRoutineToggle(
90+
dateKey = dateKey,
91+
routineId = routineId,
92+
completionInfo = RoutineCompletionInfo(routineId, routineCompleteYn = true, subRoutineCompleteYn = emptyList()),
93+
)
94+
95+
val updatedRoutine = localDataSource.routineSchedule.value
96+
?.dailyRoutines?.get(dateKey)?.routines?.find { it.id == routineId }
97+
assertTrue(updatedRoutine!!.isCompleted)
98+
}
99+
100+
@Test
101+
fun `토글 후 debounce 경과 시 서버 sync API가 호출되어야 한다`() = runTest {
102+
repository = createRepository(testScheduler)
103+
runCurrent() // repositoryScope의 syncTrigger collector 시작
104+
val (dateKey, routineId) = setupCacheWithRoutine(isCompleted = false)
105+
106+
repository.applyRoutineToggle(
107+
dateKey = dateKey,
108+
routineId = routineId,
109+
completionInfo = RoutineCompletionInfo(routineId, routineCompleteYn = true, subRoutineCompleteYn = emptyList()),
110+
)
111+
112+
assertEquals(0, remoteDataSource.syncCount.get())
113+
advanceTimeBy(501L)
114+
assertEquals(1, remoteDataSource.syncCount.get())
115+
}
116+
117+
@Test
118+
fun `A→B→A 토글 시 최종 상태가 원래와 동일하면 API를 호출하지 않아야 한다`() = runTest {
119+
repository = createRepository(testScheduler)
120+
runCurrent() // repositoryScope의 syncTrigger collector 시작
121+
val (dateKey, routineId) = setupCacheWithRoutine(isCompleted = false)
122+
val completionInfoB = RoutineCompletionInfo(routineId, routineCompleteYn = true, subRoutineCompleteYn = emptyList())
123+
val completionInfoA = RoutineCompletionInfo(routineId, routineCompleteYn = false, subRoutineCompleteYn = emptyList())
124+
125+
repository.applyRoutineToggle(dateKey, routineId, completionInfoB)
126+
repository.applyRoutineToggle(dateKey, routineId, completionInfoA)
127+
128+
advanceTimeBy(501L)
129+
assertEquals(0, remoteDataSource.syncCount.get())
130+
}
131+
132+
@Test
133+
fun `sync 실패 시 서버 데이터로 로컬 캐시가 rollback되어야 한다`() = runTest {
134+
repository = createRepository(testScheduler)
135+
runCurrent() // repositoryScope의 syncTrigger collector 시작
136+
remoteDataSource.scheduleResponse = Result.success(RoutineScheduleResponse(emptyMap()))
137+
remoteDataSource.syncResult = Result.failure(Exception("네트워크 오류"))
138+
val (dateKey, routineId) = setupCacheWithRoutine(isCompleted = false)
139+
140+
repository.applyRoutineToggle(
141+
dateKey = dateKey,
142+
routineId = routineId,
143+
completionInfo = RoutineCompletionInfo(routineId, routineCompleteYn = true, subRoutineCompleteYn = emptyList()),
144+
)
145+
advanceTimeBy(501L)
146+
147+
// sync 실패 후 fetchAndSave 호출 → 서버 값(emptyMap)으로 덮어씀
148+
assertEquals(1, remoteDataSource.fetchCount.get())
149+
assertEquals(RoutineSchedule(emptyMap()), localDataSource.routineSchedule.value)
150+
}
151+
152+
// --- delete / edit / register ---
153+
154+
@Test
155+
fun `deleteRoutine 성공 시 캐시 무효화 후 Remote를 재호출해야 한다`() = runTest {
156+
repository = createRepository(testScheduler)
157+
remoteDataSource.scheduleResponse = Result.success(RoutineScheduleResponse(emptyMap()))
158+
localDataSource.saveSchedule(RoutineSchedule(emptyMap()), "2024-01-01", "2024-01-07")
159+
160+
repository.deleteRoutine("routine1")
161+
162+
assertEquals(1, remoteDataSource.fetchCount.get())
163+
}
164+
165+
@Test
166+
fun `deleteRoutine 실패 시 Remote를 재호출하지 않아야 한다`() = runTest {
167+
repository = createRepository(testScheduler)
168+
remoteDataSource.deleteResult = Result.failure(Exception("삭제 실패"))
169+
localDataSource.saveSchedule(RoutineSchedule(emptyMap()), "2024-01-01", "2024-01-07")
170+
171+
repository.deleteRoutine("routine1")
172+
173+
assertEquals(0, remoteDataSource.fetchCount.get())
174+
}
175+
176+
// --- Helpers ---
177+
178+
private fun setupCacheWithRoutine(isCompleted: Boolean): Pair<String, String> {
179+
val dateKey = "2024-01-01"
180+
val routineId = "routine1"
181+
val schedule = RoutineSchedule(
182+
dailyRoutines = mapOf(
183+
dateKey to DailyRoutines(
184+
routines = listOf(
185+
Routine(
186+
id = routineId,
187+
name = "테스트 루틴",
188+
repeatDays = listOf(DayOfWeek.MONDAY),
189+
executionTime = "08:00",
190+
startDate = dateKey,
191+
endDate = dateKey,
192+
routineDate = dateKey,
193+
isCompleted = isCompleted,
194+
isDeleted = false,
195+
subRoutineNames = emptyList(),
196+
subRoutineCompletionStates = emptyList(),
197+
recommendedRoutineType = null,
198+
),
199+
),
200+
isAllCompleted = isCompleted,
201+
),
202+
),
203+
)
204+
localDataSource.saveSchedule(schedule, dateKey, dateKey)
205+
return dateKey to routineId
206+
}
207+
208+
// --- Fake Objects ---
209+
210+
private class FakeRoutineRemoteDataSource : RoutineRemoteDataSource {
211+
var scheduleResponse: Result<RoutineScheduleResponse> = Result.success(RoutineScheduleResponse(emptyMap()))
212+
var syncResult: Result<Unit> = Result.success(Unit)
213+
var deleteResult: Result<Unit> = Result.success(Unit)
214+
val fetchCount = AtomicInteger(0)
215+
val syncCount = AtomicInteger(0)
216+
217+
override suspend fun fetchWeeklyRoutines(startDate: String, endDate: String): Result<RoutineScheduleResponse> {
218+
fetchCount.incrementAndGet()
219+
return scheduleResponse
220+
}
221+
222+
override suspend fun syncRoutineCompletion(routineCompletionRequest: RoutineCompletionRequest): Result<Unit> {
223+
syncCount.incrementAndGet()
224+
return syncResult
225+
}
226+
227+
override suspend fun getRoutine(routineId: String): Result<com.threegap.bitnagil.data.routine.model.response.RoutineResponse> =
228+
Result.failure(NotImplementedError())
229+
230+
override suspend fun deleteRoutine(routineId: String): Result<Unit> = deleteResult
231+
232+
override suspend fun deleteRoutineForDay(routineId: String): Result<Unit> = Result.success(Unit)
233+
234+
override suspend fun registerRoutine(request: RoutineRegisterRequest): Result<Unit> = Result.success(Unit)
235+
236+
override suspend fun editRoutine(request: RoutineEditRequest): Result<Unit> = Result.success(Unit)
237+
}
238+
}

0 commit comments

Comments
 (0)