1+ package org.openedx.dates
2+
3+ import androidx.arch.core.executor.testing.InstantTaskExecutorRule
4+ import androidx.fragment.app.FragmentManager
5+ import io.mockk.coEvery
6+ import io.mockk.coVerify
7+ import io.mockk.every
8+ import io.mockk.mockk
9+ import io.mockk.verify
10+ import kotlinx.coroutines.Dispatchers
11+ import kotlinx.coroutines.ExperimentalCoroutinesApi
12+ import kotlinx.coroutines.async
13+ import kotlinx.coroutines.flow.first
14+ import kotlinx.coroutines.test.StandardTestDispatcher
15+ import kotlinx.coroutines.test.UnconfinedTestDispatcher
16+ import kotlinx.coroutines.test.advanceUntilIdle
17+ import kotlinx.coroutines.test.resetMain
18+ import kotlinx.coroutines.test.runTest
19+ import kotlinx.coroutines.test.setMain
20+ import kotlinx.coroutines.withTimeoutOrNull
21+ import org.junit.After
22+ import org.junit.Assert.assertEquals
23+ import org.junit.Assert.assertFalse
24+ import org.junit.Assert.assertTrue
25+ import org.junit.Before
26+ import org.junit.Rule
27+ import org.junit.Test
28+ import org.openedx.core.R
29+ import org.openedx.core.data.storage.CorePreferences
30+ import org.openedx.core.domain.model.CourseDate
31+ import org.openedx.core.domain.model.CourseDatesResponse
32+ import org.openedx.core.system.connection.NetworkConnection
33+ import org.openedx.dates.domain.interactor.DatesInteractor
34+ import org.openedx.dates.presentation.DatesRouter
35+ import org.openedx.dates.presentation.dates.DatesViewModel
36+ import org.openedx.foundation.presentation.UIMessage
37+ import org.openedx.foundation.system.ResourceManager
38+ import java.net.UnknownHostException
39+ import java.util.Date
40+
41+ @OptIn(ExperimentalCoroutinesApi ::class )
42+ class DatesViewModelTest {
43+
44+ @get:Rule
45+ val testInstantTaskExecutorRule = InstantTaskExecutorRule ()
46+
47+ private val dispatcher = StandardTestDispatcher ()
48+
49+ private val datesRouter = mockk<DatesRouter >(relaxed = true )
50+ private val networkConnection = mockk<NetworkConnection >()
51+ private val resourceManager = mockk<ResourceManager >()
52+ private val datesInteractor = mockk<DatesInteractor >()
53+ private val corePreferences = mockk<CorePreferences >()
54+
55+ private val noInternet = " Slow or no internet connection"
56+ private val somethingWrong = " Something went wrong"
57+
58+ @Before
59+ fun setUp () {
60+ Dispatchers .setMain(dispatcher)
61+ every { resourceManager.getString(R .string.core_error_no_connection) } returns noInternet
62+ every { resourceManager.getString(R .string.core_error_unknown_error) } returns somethingWrong
63+ // By default, assume we have an internet connection
64+ every { networkConnection.isOnline() } returns true
65+ every { corePreferences.isRelativeDatesEnabled } returns true
66+ }
67+
68+ @After
69+ fun tearDown () {
70+ Dispatchers .resetMain()
71+ }
72+
73+ @Test
74+ fun `init fetchDates online with pagination` () = runTest {
75+ // Create a dummy CourseDate; grouping is done inside the view model so the exact grouping is not under test.
76+ val courseDate: CourseDate = mockk(relaxed = true )
77+ val courseDatesResponse = CourseDatesResponse (
78+ count = 10 ,
79+ next = 2 ,
80+ previous = 1 ,
81+ results = listOf (courseDate)
82+ )
83+ coEvery { datesInteractor.getUserDates(1 ) } returns courseDatesResponse
84+
85+ // Instantiate the view model; fetchDates is called in init.
86+ val viewModel = DatesViewModel (
87+ datesRouter,
88+ networkConnection,
89+ resourceManager,
90+ datesInteractor,
91+ corePreferences
92+ )
93+ advanceUntilIdle()
94+
95+ coVerify(exactly = 1 ) { datesInteractor.getUserDates(1 ) }
96+ // Since next is not null and page (1) != count (10), canLoadMore should be true.
97+ assertFalse(viewModel.uiState.value.isLoading)
98+ assertTrue(viewModel.uiState.value.canLoadMore)
99+ }
100+
101+ @Test
102+ fun `init fetchDates offline uses cache` () = runTest {
103+ every { networkConnection.isOnline() } returns false
104+ val cachedCourseDate: CourseDate = mockk(relaxed = true )
105+ coEvery { datesInteractor.getUserDatesFromCache() } returns listOf (cachedCourseDate)
106+
107+ val viewModel = DatesViewModel (
108+ datesRouter,
109+ networkConnection,
110+ resourceManager,
111+ datesInteractor,
112+ corePreferences
113+ )
114+ advanceUntilIdle()
115+
116+ // When offline, getUserDates is not called.
117+ coVerify(exactly = 0 ) { datesInteractor.getUserDates(any()) }
118+ coVerify(exactly = 1 ) { datesInteractor.getUserDatesFromCache() }
119+ assertFalse(viewModel.uiState.value.isLoading)
120+ // Expect no further pages to load.
121+ assertFalse(viewModel.uiState.value.canLoadMore)
122+ }
123+
124+ @Test
125+ fun `fetchDates unknown error emits unknown error message` () =
126+ runTest(UnconfinedTestDispatcher ()) {
127+ every { networkConnection.isOnline() } returns true
128+
129+ val viewModel = DatesViewModel (
130+ datesRouter,
131+ networkConnection,
132+ resourceManager,
133+ datesInteractor,
134+ corePreferences
135+ )
136+ val message = async {
137+ withTimeoutOrNull(5000 ) {
138+ viewModel.uiMessage.first() as ? UIMessage .SnackBarMessage
139+ }
140+ }
141+ advanceUntilIdle()
142+
143+ assertEquals(somethingWrong, message.await()?.message)
144+ assertFalse(viewModel.uiState.value.isLoading)
145+ }
146+
147+ @Test
148+ fun `fetchDates internet error emits no connection message` () =
149+ runTest(UnconfinedTestDispatcher ()) {
150+ every { networkConnection.isOnline() } returns true
151+ coEvery { datesInteractor.getUserDates(any()) } throws UnknownHostException ()
152+
153+ val viewModel = DatesViewModel (
154+ datesRouter,
155+ networkConnection,
156+ resourceManager,
157+ datesInteractor,
158+ corePreferences
159+ )
160+ val message = async {
161+ withTimeoutOrNull(5000 ) {
162+ viewModel.uiMessage.first() as ? UIMessage .SnackBarMessage
163+ }
164+ }
165+ advanceUntilIdle()
166+
167+ assertEquals(noInternet, message.await()?.message)
168+ assertFalse(viewModel.uiState.value.isLoading)
169+ }
170+
171+ @Test
172+ fun `shiftDueDate success` () = runTest {
173+ every { networkConnection.isOnline() } returns true
174+ // Prepare a dummy CourseDate that qualifies as past due and is marked as relative.
175+ val courseDate: CourseDate = mockk(relaxed = true ) {
176+ every { relative } returns true
177+ every { courseId } returns " course-123"
178+ // Set dueDate to yesterday.
179+ every { dueDate } returns Date (System .currentTimeMillis() - 24 * 60 * 60 * 1000 )
180+ }
181+ val courseDatesResponse = CourseDatesResponse (
182+ count = 1 ,
183+ next = null ,
184+ previous = null ,
185+ results = listOf (courseDate)
186+ )
187+ coEvery { datesInteractor.getUserDates(1 ) } returns courseDatesResponse
188+ // When refreshData is triggered from shiftDueDate, return the same response.
189+ coEvery { datesInteractor.getUserDates(any()) } returns courseDatesResponse
190+
191+ val viewModel = DatesViewModel (
192+ datesRouter,
193+ networkConnection,
194+ resourceManager,
195+ datesInteractor,
196+ corePreferences
197+ )
198+ advanceUntilIdle()
199+
200+ viewModel.shiftDueDate()
201+ advanceUntilIdle()
202+
203+ coVerify { datesInteractor.shiftDueDate(listOf (" course-123" )) }
204+ // isShiftDueDatesPressed should be reset to false after processing.
205+ assertFalse(viewModel.uiState.value.isShiftDueDatesPressed)
206+ }
207+
208+ @Test
209+ fun `shiftDueDate error emits error message and resets flag` () =
210+ runTest(UnconfinedTestDispatcher ()) {
211+ every { networkConnection.isOnline() } returns true
212+ val courseDate: CourseDate = mockk(relaxed = true ) {
213+ every { relative } returns true
214+ every { courseId } returns " course-123"
215+ every { dueDate } returns Date (System .currentTimeMillis() - 24 * 60 * 60 * 1000 )
216+ }
217+ val courseDatesResponse = CourseDatesResponse (
218+ count = 1 ,
219+ next = null ,
220+ previous = null ,
221+ results = listOf (courseDate)
222+ )
223+ coEvery { datesInteractor.getUserDates(1 ) } returns courseDatesResponse
224+ coEvery { datesInteractor.shiftDueDate(any()) } throws Exception ()
225+
226+ val viewModel = DatesViewModel (
227+ datesRouter,
228+ networkConnection,
229+ resourceManager,
230+ datesInteractor,
231+ corePreferences
232+ )
233+ advanceUntilIdle()
234+
235+ viewModel.shiftDueDate()
236+ val message = async {
237+ withTimeoutOrNull(5000 ) {
238+ viewModel.uiMessage.first() as ? UIMessage .SnackBarMessage
239+ }
240+ }
241+ advanceUntilIdle()
242+
243+ assertEquals(somethingWrong, message.await()?.message)
244+ assertFalse(viewModel.uiState.value.isShiftDueDatesPressed)
245+ }
246+
247+ @Test
248+ fun `onSettingsClick navigates to settings` () = runTest {
249+ val viewModel = DatesViewModel (
250+ datesRouter,
251+ networkConnection,
252+ resourceManager,
253+ datesInteractor,
254+ corePreferences
255+ )
256+ val fragmentManager = mockk<FragmentManager >(relaxed = true )
257+
258+ viewModel.onSettingsClick(fragmentManager)
259+ verify { datesRouter.navigateToSettings(fragmentManager) }
260+ }
261+
262+ @Test
263+ fun `navigateToCourseOutline calls router with correct parameters` () = runTest {
264+ val viewModel = DatesViewModel (
265+ datesRouter,
266+ networkConnection,
267+ resourceManager,
268+ datesInteractor,
269+ corePreferences
270+ )
271+ val fragmentManager = mockk<FragmentManager >(relaxed = true )
272+ val courseDate: CourseDate = mockk(relaxed = true ) {
273+ every { courseId } returns " course-123"
274+ every { courseName } returns " Test Course"
275+ every { assignmentBlockId } returns " block-1"
276+ }
277+
278+ viewModel.navigateToCourseOutline(fragmentManager, courseDate)
279+ verify {
280+ datesRouter.navigateToCourseOutline(
281+ fm = fragmentManager,
282+ courseId = " course-123" ,
283+ courseTitle = " Test Course" ,
284+ openTab = " " ,
285+ resumeBlockId = " block-1"
286+ )
287+ }
288+ }
289+
290+ @Test
291+ fun `fetchMore calls fetchDates when allowed` () = runTest {
292+ every { networkConnection.isOnline() } returns true
293+ val courseDate: CourseDate = mockk(relaxed = true )
294+ val courseDatesResponse = CourseDatesResponse (
295+ count = 10 ,
296+ next = 2 ,
297+ previous = 1 ,
298+ results = listOf (courseDate)
299+ )
300+
301+ // Initial fetch on page 1.
302+ coEvery { datesInteractor.getUserDates(1 ) } returns courseDatesResponse
303+ // For subsequent fetch, we return a similar response.
304+ coEvery { datesInteractor.getUserDates(any()) } returns courseDatesResponse
305+
306+ val viewModel = DatesViewModel (
307+ datesRouter,
308+ networkConnection,
309+ resourceManager,
310+ datesInteractor,
311+ corePreferences
312+ )
313+ advanceUntilIdle()
314+
315+ viewModel.fetchMore()
316+ advanceUntilIdle()
317+
318+ // Expect two calls (one from init and one from fetchMore)
319+ coVerify(exactly = 2 ) { datesInteractor.getUserDates(any()) }
320+ }
321+
322+ @Test
323+ fun `refreshData calls fetchDates with refresh true` () = runTest {
324+ every { networkConnection.isOnline() } returns true
325+ val courseDate: CourseDate = mockk(relaxed = true )
326+ val courseDatesResponse = CourseDatesResponse (
327+ count = 1 ,
328+ next = null ,
329+ previous = null ,
330+ results = listOf (courseDate)
331+ )
332+ // Initial fetch.
333+ coEvery { datesInteractor.getUserDates(1 ) } returns courseDatesResponse
334+ // For refresh, return the same response.
335+ coEvery { datesInteractor.getUserDates(any()) } returns courseDatesResponse
336+
337+ val viewModel = DatesViewModel (
338+ datesRouter,
339+ networkConnection,
340+ resourceManager,
341+ datesInteractor,
342+ corePreferences
343+ )
344+ advanceUntilIdle()
345+
346+ viewModel.refreshData()
347+ advanceUntilIdle()
348+
349+ // Two calls: one on init, one on refresh.
350+ coVerify(exactly = 2 ) { datesInteractor.getUserDates(any()) }
351+ // After refresh, isRefreshing should be false.
352+ assertFalse(viewModel.uiState.value.isRefreshing)
353+ }
354+ }
0 commit comments