@@ -57,6 +57,7 @@ describe("useGcalSync", () => {
5757
5858 beforeEach ( ( ) => {
5959 jest . clearAllMocks ( ) ;
60+ jest . useFakeTimers ( ) ;
6061 importingValue = false ;
6162 awaitingValue = false ;
6263 ( useDispatch as jest . Mock ) . mockReturnValue ( mockDispatch ) ;
@@ -71,6 +72,10 @@ describe("useGcalSync", () => {
7172 } ) ;
7273 } ) ;
7374
75+ afterEach ( ( ) => {
76+ jest . useRealTimers ( ) ;
77+ } ) ;
78+
7479 it ( "sets up socket listeners" , ( ) => {
7580 renderHook ( ( ) => useGcalSync ( ) ) ;
7681
@@ -191,4 +196,203 @@ describe("useGcalSync", () => {
191196 ) ;
192197 } ) ;
193198 } ) ;
199+
200+ describe ( "import flow interaction" , ( ) => {
201+ it ( "shows spinner on import start and hides it on successful import end" , ( ) => {
202+ // Capture socket handlers to simulate backend events
203+ const handlers : Record < string , ( ...args : unknown [ ] ) => void > = { } ;
204+ ( socket . on as jest . Mock ) . mockImplementation ( ( event , handler ) => {
205+ handlers [ event ] = handler ;
206+ } ) ;
207+
208+ awaitingValue = true ;
209+ renderHook ( ( ) => useGcalSync ( ) ) ;
210+
211+ // Verify handlers are registered
212+ expect ( handlers [ IMPORT_GCAL_START ] ) . toBeDefined ( ) ;
213+ expect ( handlers [ IMPORT_GCAL_END ] ) . toBeDefined ( ) ;
214+
215+ // Phase 1: Backend signals import start (spinner should appear)
216+ handlers [ IMPORT_GCAL_START ] ( true ) ;
217+
218+ expect ( mockDispatch ) . toHaveBeenCalledWith (
219+ importGCalSlice . actions . clearImportResults ( undefined ) ,
220+ ) ;
221+ expect ( mockDispatch ) . toHaveBeenCalledWith (
222+ importGCalSlice . actions . importing ( true ) ,
223+ ) ;
224+
225+ mockDispatch . mockClear ( ) ;
226+
227+ // Phase 2: Simulate backend processing time (e.g., 2 seconds)
228+ jest . advanceTimersByTime ( 2000 ) ;
229+
230+ // Phase 3: Backend signals import complete with successful response
231+ const successfulResponse = JSON . stringify ( {
232+ eventsCount : 25 ,
233+ calendarsCount : 3 ,
234+ } ) ;
235+ handlers [ IMPORT_GCAL_END ] ( successfulResponse ) ;
236+
237+ // Spinner should disappear (importing set to false)
238+ expect ( mockDispatch ) . toHaveBeenCalledWith (
239+ importGCalSlice . actions . importing ( false ) ,
240+ ) ;
241+ // Results should be set
242+ expect ( mockDispatch ) . toHaveBeenCalledWith (
243+ importGCalSlice . actions . setImportResults ( {
244+ eventsCount : 25 ,
245+ calendarsCount : 3 ,
246+ } ) ,
247+ ) ;
248+ // Fetch should be triggered to load new events
249+ expect ( triggerFetch ) . toHaveBeenCalledWith ( {
250+ reason : "IMPORT_COMPLETE" ,
251+ } ) ;
252+ } ) ;
253+
254+ it ( "hides spinner when import completes within timeout" , ( ) => {
255+ const handlers : Record < string , ( ...args : unknown [ ] ) => void > = { } ;
256+ ( socket . on as jest . Mock ) . mockImplementation ( ( event , handler ) => {
257+ handlers [ event ] = handler ;
258+ } ) ;
259+
260+ awaitingValue = true ;
261+ renderHook ( ( ) => useGcalSync ( ) ) ;
262+
263+ // Start import
264+ handlers [ IMPORT_GCAL_START ] ( true ) ;
265+ mockDispatch . mockClear ( ) ;
266+
267+ // Simulate a reasonable import duration (under 30 seconds)
268+ const REASONABLE_IMPORT_TIME_MS = 15000 ;
269+ jest . advanceTimersByTime ( REASONABLE_IMPORT_TIME_MS ) ;
270+
271+ // Import completes successfully
272+ handlers [ IMPORT_GCAL_END ] (
273+ JSON . stringify ( { eventsCount : 100 , calendarsCount : 5 } ) ,
274+ ) ;
275+
276+ // Verify spinner is hidden
277+ expect ( mockDispatch ) . toHaveBeenCalledWith (
278+ importGCalSlice . actions . importing ( false ) ,
279+ ) ;
280+ } ) ;
281+
282+ it ( "handles rapid start/end sequence without state inconsistency" , ( ) => {
283+ const handlers : Record < string , ( ...args : unknown [ ] ) => void > = { } ;
284+ ( socket . on as jest . Mock ) . mockImplementation ( ( event , handler ) => {
285+ handlers [ event ] = handler ;
286+ } ) ;
287+
288+ awaitingValue = true ;
289+ renderHook ( ( ) => useGcalSync ( ) ) ;
290+
291+ // Rapid sequence: start → end (small import)
292+ handlers [ IMPORT_GCAL_START ] ( true ) ;
293+ jest . advanceTimersByTime ( 100 ) ; // Very fast import
294+ handlers [ IMPORT_GCAL_END ] (
295+ JSON . stringify ( { eventsCount : 2 , calendarsCount : 1 } ) ,
296+ ) ;
297+
298+ // Final state should have importing=false
299+ const importingCalls = mockDispatch . mock . calls . filter (
300+ ( call ) =>
301+ call [ 0 ] === importGCalSlice . actions . importing ( true ) ||
302+ call [ 0 ] === importGCalSlice . actions . importing ( false ) ,
303+ ) ;
304+
305+ // Last importing call should be false (spinner hidden)
306+ expect ( mockDispatch ) . toHaveBeenLastCalledWith (
307+ importGCalSlice . actions . setImportResults ( {
308+ eventsCount : 2 ,
309+ calendarsCount : 1 ,
310+ } ) ,
311+ ) ;
312+ } ) ;
313+
314+ it ( "handles import end with empty payload gracefully" , ( ) => {
315+ const handlers : Record < string , ( ...args : unknown [ ] ) => void > = { } ;
316+ ( socket . on as jest . Mock ) . mockImplementation ( ( event , handler ) => {
317+ handlers [ event ] = handler ;
318+ } ) ;
319+
320+ awaitingValue = true ;
321+ renderHook ( ( ) => useGcalSync ( ) ) ;
322+
323+ handlers [ IMPORT_GCAL_START ] ( true ) ;
324+ mockDispatch . mockClear ( ) ;
325+
326+ // Backend sends empty response (edge case)
327+ handlers [ IMPORT_GCAL_END ] ( JSON . stringify ( { } ) ) ;
328+
329+ // Should still hide spinner and set empty results
330+ expect ( mockDispatch ) . toHaveBeenCalledWith (
331+ importGCalSlice . actions . importing ( false ) ,
332+ ) ;
333+ expect ( mockDispatch ) . toHaveBeenCalledWith (
334+ importGCalSlice . actions . setImportResults ( { } ) ,
335+ ) ;
336+ } ) ;
337+
338+ it ( "handles import end with object payload (non-string)" , ( ) => {
339+ const handlers : Record < string , ( ...args : unknown [ ] ) => void > = { } ;
340+ ( socket . on as jest . Mock ) . mockImplementation ( ( event , handler ) => {
341+ handlers [ event ] = handler ;
342+ } ) ;
343+
344+ awaitingValue = true ;
345+ renderHook ( ( ) => useGcalSync ( ) ) ;
346+
347+ handlers [ IMPORT_GCAL_START ] ( true ) ;
348+ mockDispatch . mockClear ( ) ;
349+
350+ // Backend sends object directly (alternative format)
351+ handlers [ IMPORT_GCAL_END ] ( { eventsCount : 50 , calendarsCount : 4 } ) ;
352+
353+ expect ( mockDispatch ) . toHaveBeenCalledWith (
354+ importGCalSlice . actions . importing ( false ) ,
355+ ) ;
356+ expect ( mockDispatch ) . toHaveBeenCalledWith (
357+ importGCalSlice . actions . setImportResults ( {
358+ eventsCount : 50 ,
359+ calendarsCount : 4 ,
360+ } ) ,
361+ ) ;
362+ } ) ;
363+
364+ it ( "sets error state when backend returns malformed JSON" , ( ) => {
365+ const handlers : Record < string , ( ...args : unknown [ ] ) => void > = { } ;
366+ ( socket . on as jest . Mock ) . mockImplementation ( ( event , handler ) => {
367+ handlers [ event ] = handler ;
368+ } ) ;
369+ const consoleErrorSpy = jest
370+ . spyOn ( console , "error" )
371+ . mockImplementation ( ( ) => { } ) ;
372+
373+ awaitingValue = true ;
374+ renderHook ( ( ) => useGcalSync ( ) ) ;
375+
376+ handlers [ IMPORT_GCAL_START ] ( true ) ;
377+ mockDispatch . mockClear ( ) ;
378+
379+ // Backend sends malformed response
380+ handlers [ IMPORT_GCAL_END ] ( "not valid json {{{" ) ;
381+
382+ // Should hide spinner
383+ expect ( mockDispatch ) . toHaveBeenCalledWith (
384+ importGCalSlice . actions . importing ( false ) ,
385+ ) ;
386+ // Should set error
387+ expect ( mockDispatch ) . toHaveBeenCalledWith (
388+ importGCalSlice . actions . setImportError (
389+ "Failed to parse Google Calendar import results." ,
390+ ) ,
391+ ) ;
392+ // Should NOT set results
393+ expect ( importGCalSlice . actions . setImportResults ) . not . toHaveBeenCalled ( ) ;
394+
395+ consoleErrorSpy . mockRestore ( ) ;
396+ } ) ;
397+ } ) ;
194398} ) ;
0 commit comments