diff --git a/.github/ISSUE_TEMPLATE/1-feature-request.yml b/.github/ISSUE_TEMPLATE/1-feature-request.yml index 4bb6fa7f2..52926b8ea 100644 --- a/.github/ISSUE_TEMPLATE/1-feature-request.yml +++ b/.github/ISSUE_TEMPLATE/1-feature-request.yml @@ -1,4 +1,4 @@ -name: Feature Request ✨ +name: Feature Request description: You want something added to the app labels: [enhancement] projects: [SwitchbackTech/4] @@ -9,17 +9,6 @@ body: value: | Thanks for taking the time to fill out this feature request! - - type: dropdown - attributes: - label: Priority - description: How important is this feature to you? - multiple: false - options: - - Critical (blocking my workflow) - - High (would significantly improve my experience) - - Medium (nice to have) - - Low (minor improvement) - - type: textarea attributes: label: Feature Description diff --git a/.github/ISSUE_TEMPLATE/2-bug-report.yml b/.github/ISSUE_TEMPLATE/2-bug-report.yml index 4b7b9f5c3..24ffcff7d 100644 --- a/.github/ISSUE_TEMPLATE/2-bug-report.yml +++ b/.github/ISSUE_TEMPLATE/2-bug-report.yml @@ -1,4 +1,4 @@ -name: Bug Report 🐞 +name: Bug Report description: You found something wrong labels: [bug] projects: [SwitchbackTech/4] diff --git a/.github/ISSUE_TEMPLATE/config.yml b/.github/ISSUE_TEMPLATE/config.yml index 014f6d8b7..aca69a956 100644 --- a/.github/ISSUE_TEMPLATE/config.yml +++ b/.github/ISSUE_TEMPLATE/config.yml @@ -1,8 +1,8 @@ blank_issues_enabled: true contact_links: - - name: 💬 GitHub Discussions + - name: GitHub Discussions url: https://github.com/SwitchbackTech/compass/discussions about: Start a GitHub Discussion for general questions and feedback - - name: 📚 Documentation + - name: Documentation url: https://docs.compasscalendar.com about: Check our documentation for setup guides, API reference, and contributing guidelines diff --git a/e2e/utils/oauth-test-utils.ts b/e2e/utils/oauth-test-utils.ts index 76942af1a..b3cdd3c10 100644 --- a/e2e/utils/oauth-test-utils.ts +++ b/e2e/utils/oauth-test-utils.ts @@ -74,13 +74,13 @@ export const setIsSyncing = async (page: Page, value: boolean) => { /** * Set the awaitingImportResults state in Redux (triggers import phase when true). */ -export const setAwaitingImportResults = async (page: Page, value: boolean) => { - await page.evaluate((awaitingValue) => { +export const setIsImportPending = async (page: Page, value: boolean) => { + await page.evaluate((isImportPendingValue) => { const store = (window as any).__COMPASS_STORE__; if (store) { store.dispatch({ - type: "async/importGCal/setAwaitingImportResults", - payload: awaitingValue, + type: "async/importGCal/setIsImportPending", + payload: isImportPendingValue, }); } }, value); diff --git a/packages/web/src/__tests__/utils/state/store.test.util.ts b/packages/web/src/__tests__/utils/state/store.test.util.ts index b1fca9ca5..e99901784 100644 --- a/packages/web/src/__tests__/utils/state/store.test.util.ts +++ b/packages/web/src/__tests__/utils/state/store.test.util.ts @@ -122,7 +122,7 @@ export const createInitialState = ( importing: false, importResults: null, pendingLocalEventsSynced: null, - awaitingImportResults: false, + isImportPending: false, importError: null, }, importLatest: { diff --git a/packages/web/src/auth/hooks/oauth/useGoogleAuth.test.ts b/packages/web/src/auth/hooks/oauth/useGoogleAuth.test.ts index e97d810e3..3cf268cc6 100644 --- a/packages/web/src/auth/hooks/oauth/useGoogleAuth.test.ts +++ b/packages/web/src/auth/hooks/oauth/useGoogleAuth.test.ts @@ -161,7 +161,7 @@ describe("useGoogleAuth", () => { ); expect(mockDispatchFn).toHaveBeenCalledWith( expect.objectContaining({ - type: "async/importGCal/setAwaitingImportResults", + type: "async/importGCal/setIsImportPending", payload: true, }), ); @@ -193,7 +193,7 @@ describe("useGoogleAuth", () => { expect(mockDispatchFn).toHaveBeenCalledWith( expect.objectContaining({ - type: "async/importGCal/setAwaitingImportResults", + type: "async/importGCal/setIsImportPending", payload: false, }), ); @@ -252,7 +252,7 @@ describe("useGoogleAuth", () => { expect(mockSetAuthenticated).not.toHaveBeenCalled(); expect(mockDispatchFn).toHaveBeenCalledWith( expect.objectContaining({ - type: "async/importGCal/setAwaitingImportResults", + type: "async/importGCal/setIsImportPending", payload: false, }), ); @@ -298,7 +298,7 @@ describe("useGoogleAuth", () => { expect(mockDispatchFn).toHaveBeenCalledWith( expect.objectContaining({ - type: "async/importGCal/setAwaitingImportResults", + type: "async/importGCal/setIsImportPending", payload: false, }), ); diff --git a/packages/web/src/auth/hooks/oauth/useGoogleAuth.ts b/packages/web/src/auth/hooks/oauth/useGoogleAuth.ts index 779b6c759..f1d3b7165 100644 --- a/packages/web/src/auth/hooks/oauth/useGoogleAuth.ts +++ b/packages/web/src/auth/hooks/oauth/useGoogleAuth.ts @@ -31,7 +31,7 @@ export function useGoogleAuth() { onStart: () => { dismissErrorToast(SESSION_EXPIRED_TOAST_ID); dispatch(startAuthenticating()); - dispatch(importGCalSlice.actions.setAwaitingImportResults(true)); + dispatch(importGCalSlice.actions.setIsImportPending(true)); dispatch(importGCalSlice.actions.clearImportResults(undefined)); }, onSuccess: async (data) => { @@ -42,7 +42,7 @@ export function useGoogleAuth() { dispatch( authError(authResult.error?.message || "Authentication failed"), ); - dispatch(importGCalSlice.actions.setAwaitingImportResults(false)); + dispatch(importGCalSlice.actions.setIsImportPending(false)); dispatch(importGCalSlice.actions.importing(false)); return; } @@ -59,7 +59,7 @@ export function useGoogleAuth() { dispatch(authSuccess()); // Now that OAuth is complete, indicate that calendar import is starting dispatch(importGCalSlice.actions.importing(true)); - dispatch(importGCalSlice.actions.setAwaitingImportResults(true)); + dispatch(importGCalSlice.actions.setIsImportPending(true)); }); const syncResult = await syncLocalEvents(); @@ -90,7 +90,7 @@ export function useGoogleAuth() { error instanceof Error ? error.message : "Authentication failed", ), ); - dispatch(importGCalSlice.actions.setAwaitingImportResults(false)); + dispatch(importGCalSlice.actions.setIsImportPending(false)); dispatch(importGCalSlice.actions.importing(false)); throw error; // Re-throw so useGoogleLoginWithSyncOverlay can handle it via onError } @@ -102,7 +102,7 @@ export function useGoogleAuth() { error instanceof Error ? error.message : "Authentication failed", ), ); - dispatch(importGCalSlice.actions.setAwaitingImportResults(false)); + dispatch(importGCalSlice.actions.setIsImportPending(false)); dispatch(importGCalSlice.actions.importing(false)); }, }); diff --git a/packages/web/src/components/SyncEventsOverlay/SyncEventsOverlay.test.tsx b/packages/web/src/components/SyncEventsOverlay/SyncEventsOverlay.test.tsx index b6fb43a6b..92808b2f1 100644 --- a/packages/web/src/components/SyncEventsOverlay/SyncEventsOverlay.test.tsx +++ b/packages/web/src/components/SyncEventsOverlay/SyncEventsOverlay.test.tsx @@ -3,8 +3,8 @@ import "@testing-library/jest-dom"; import { render, screen } from "@testing-library/react"; import { selectIsAuthenticating } from "@web/ducks/auth/selectors/auth.selectors"; import { - selectAwaitingImportResults, selectImporting, + selectIsImportPending, } from "@web/ducks/events/selectors/sync.selector"; import { useAppSelector } from "@web/store/store.hooks"; import { SyncEventsOverlay } from "./SyncEventsOverlay"; @@ -33,7 +33,7 @@ describe("SyncEventsOverlay", () => { if (selector === selectImporting) { return importingValue; } - if (selector === selectAwaitingImportResults) { + if (selector === selectIsImportPending) { return awaitingValue; } if (selector === selectIsAuthenticating) { diff --git a/packages/web/src/components/SyncEventsOverlay/SyncEventsOverlay.tsx b/packages/web/src/components/SyncEventsOverlay/SyncEventsOverlay.tsx index a8947173f..071c3fa50 100644 --- a/packages/web/src/components/SyncEventsOverlay/SyncEventsOverlay.tsx +++ b/packages/web/src/components/SyncEventsOverlay/SyncEventsOverlay.tsx @@ -3,14 +3,14 @@ import { useBufferedVisibility } from "@web/common/hooks/useBufferedVisibility"; import { OverlayPanel } from "@web/components/OverlayPanel/OverlayPanel"; import { selectIsAuthenticating } from "@web/ducks/auth/selectors/auth.selectors"; import { - selectAwaitingImportResults, selectImporting, + selectIsImportPending, } from "@web/ducks/events/selectors/sync.selector"; import { useAppSelector } from "@web/store/store.hooks"; export const SyncEventsOverlay = () => { const importing = useAppSelector(selectImporting); - const awaitingImportResults = useAppSelector(selectAwaitingImportResults); + const awaitingImportResults = useAppSelector(selectIsImportPending); const isAuthenticating = useAppSelector(selectIsAuthenticating); // Show overlay when: diff --git a/packages/web/src/ducks/events/selectors/sync.selector.ts b/packages/web/src/ducks/events/selectors/sync.selector.ts index ee8e81f10..e3473ee0b 100644 --- a/packages/web/src/ducks/events/selectors/sync.selector.ts +++ b/packages/web/src/ducks/events/selectors/sync.selector.ts @@ -8,8 +8,8 @@ export const selectImportGCalState = ({ sync }: RootState) => sync.importGCal; export const selectImporting = ({ sync }: RootState) => sync.importGCal.importing; -export const selectAwaitingImportResults = ({ sync }: RootState) => - sync.importGCal.awaitingImportResults; +export const selectIsImportPending = ({ sync }: RootState) => + sync.importGCal.isImportPending; export const selectImportResults = ({ sync }: RootState) => sync.importGCal.importResults; diff --git a/packages/web/src/ducks/events/slices/sync.slice.ts b/packages/web/src/ducks/events/slices/sync.slice.ts index 6de17c02a..a2e8575ba 100644 --- a/packages/web/src/ducks/events/slices/sync.slice.ts +++ b/packages/web/src/ducks/events/slices/sync.slice.ts @@ -30,7 +30,7 @@ export const importGCalSlice = createAsyncSlice< importing: boolean; importResults: ImportResults | null; pendingLocalEventsSynced: number | null; - awaitingImportResults: boolean; + isImportPending: boolean; importError: string | null; } >({ @@ -39,15 +39,15 @@ export const importGCalSlice = createAsyncSlice< importing: false, importResults: null, pendingLocalEventsSynced: null, - awaitingImportResults: false, + isImportPending: false, importError: null, }, reducers: { importing: (state, action: PayloadAction) => { state.importing = action.payload; }, - setAwaitingImportResults: (state, action: PayloadAction) => { - state.awaitingImportResults = action.payload; + setIsImportPending: (state, action: PayloadAction) => { + state.isImportPending = action.payload; if (action.payload) { state.importError = null; } @@ -63,7 +63,7 @@ export const importGCalSlice = createAsyncSlice< }>, ) => { state.importing = false; - state.awaitingImportResults = false; + state.isImportPending = false; state.importError = null; state.importResults = { ...action.payload, @@ -73,7 +73,7 @@ export const importGCalSlice = createAsyncSlice< }, setImportError: (state, action: PayloadAction) => { state.importing = false; - state.awaitingImportResults = false; + state.isImportPending = false; state.importError = action.payload; state.importResults = null; state.pendingLocalEventsSynced = null; diff --git a/packages/web/src/public/svg/circle.svg b/packages/web/src/public/svg/circle.svg deleted file mode 100644 index 8ea965c0b..000000000 --- a/packages/web/src/public/svg/circle.svg +++ /dev/null @@ -1,5 +0,0 @@ - - - - \ No newline at end of file diff --git a/packages/web/src/socket/client/socket.client.test.ts b/packages/web/src/socket/client/socket.client.test.ts index e65b4a455..a1c1120eb 100644 --- a/packages/web/src/socket/client/socket.client.test.ts +++ b/packages/web/src/socket/client/socket.client.test.ts @@ -65,14 +65,10 @@ describe("socket.client", () => { }); describe("reconnect", () => { - it("disconnects and then connects after 1 second", () => { + it("disconnects and then connects immediately", () => { socketClientModule.reconnect(); expect(mockSocket.disconnect).toHaveBeenCalled(); - expect(mockSocket.connect).not.toHaveBeenCalled(); - - jest.advanceTimersByTime(1000); - expect(mockSocket.connect).toHaveBeenCalled(); }); }); diff --git a/packages/web/src/socket/client/socket.client.ts b/packages/web/src/socket/client/socket.client.ts index 1472e1be8..981baa205 100644 --- a/packages/web/src/socket/client/socket.client.ts +++ b/packages/web/src/socket/client/socket.client.ts @@ -16,11 +16,7 @@ export const disconnect = () => { export const reconnect = () => { disconnect(); - - const timeout = setTimeout(() => { - socket.connect(); - clearTimeout(timeout); - }, 1000); + socket.connect(); }; const onError = (error: unknown) => { diff --git a/packages/web/src/socket/hooks/useGcalSync.test.ts b/packages/web/src/socket/hooks/useGcalSync.test.ts index b4149ab21..6331a07fc 100644 --- a/packages/web/src/socket/hooks/useGcalSync.test.ts +++ b/packages/web/src/socket/hooks/useGcalSync.test.ts @@ -6,8 +6,8 @@ import { USER_METADATA, } from "@core/constants/websocket.constants"; import { - selectAwaitingImportResults, selectImporting, + selectIsImportPending, } from "@web/ducks/events/selectors/sync.selector"; import { importGCalSlice, @@ -64,7 +64,7 @@ describe("useGcalSync", () => { if (selector === selectImporting) { return importingValue; } - if (selector === selectAwaitingImportResults) { + if (selector === selectIsImportPending) { return awaitingValue; } return false; @@ -87,7 +87,7 @@ describe("useGcalSync", () => { describe("IMPORT_GCAL_START", () => { it("handles import start correctly", () => { - let importStartHandler: (importing?: boolean) => void; + let importStartHandler: ((importing?: boolean) => void) | undefined; (socket.on as jest.Mock).mockImplementation((event, handler) => { if (event === IMPORT_GCAL_START) { importStartHandler = handler; @@ -111,7 +111,7 @@ describe("useGcalSync", () => { it("sets results when awaiting import results", () => { awaitingValue = true; - let importEndHandler: (data: string) => void; + let importEndHandler: ((data: string) => void) | undefined; (socket.on as jest.Mock).mockImplementation((event, handler) => { if (event === IMPORT_GCAL_END) { importEndHandler = handler; @@ -141,7 +141,7 @@ describe("useGcalSync", () => { it("does not set results when not awaiting import results", () => { awaitingValue = false; - let importEndHandler: (data: string) => void; + let importEndHandler: ((data: string) => void) | undefined; (socket.on as jest.Mock).mockImplementation((event, handler) => { if (event === IMPORT_GCAL_END) { importEndHandler = handler; @@ -157,5 +157,237 @@ describe("useGcalSync", () => { ); expect(importGCalSlice.actions.setImportResults).not.toHaveBeenCalled(); }); + + it("handles import end when awaitingImportResults changes mid-render", () => { + // Simulate the race condition: starts false, changes to true, + // then event arrives (testing ref pattern works correctly) + + let importEndHandler: ((data: string) => void) | undefined; + (socket.on as jest.Mock).mockImplementation((event, handler) => { + if (event === IMPORT_GCAL_END) { + importEndHandler = handler; + } + }); + + // Start with awaitingImportResults = false + awaitingValue = false; + const { rerender } = renderHook(() => useGcalSync()); + + // Change to true (simulating user clicking Reconnect) + awaitingValue = true; + rerender(); + + // Event arrives - should process correctly with ref pattern + importEndHandler?.( + JSON.stringify({ eventsCount: 10, calendarsCount: 2 }), + ); + + // Verify setImportResults was called (not skipped due to stale closure) + expect(mockDispatch).toHaveBeenCalledWith( + importGCalSlice.actions.setImportResults({ + eventsCount: 10, + calendarsCount: 2, + }), + ); + }); + }); + + describe("import flow interaction", () => { + it("shows spinner on import start and hides it on successful import end", () => { + // Capture socket handlers to simulate backend events + const handlers: Record void> = {}; + (socket.on as jest.Mock).mockImplementation((event, handler) => { + handlers[event] = handler; + }); + + awaitingValue = true; + renderHook(() => useGcalSync()); + + // Verify handlers are registered + expect(handlers[IMPORT_GCAL_START]).toBeDefined(); + expect(handlers[IMPORT_GCAL_END]).toBeDefined(); + + // Phase 1: Backend signals import start (spinner should appear) + handlers[IMPORT_GCAL_START](true); + + expect(mockDispatch).toHaveBeenCalledWith( + importGCalSlice.actions.clearImportResults(undefined), + ); + expect(mockDispatch).toHaveBeenCalledWith( + importGCalSlice.actions.importing(true), + ); + + mockDispatch.mockClear(); + + // Phase 2: Simulate backend processing time (e.g., 2 seconds) + jest.advanceTimersByTime(2000); + + // Phase 3: Backend signals import complete with successful response + const successfulResponse = JSON.stringify({ + eventsCount: 25, + calendarsCount: 3, + }); + handlers[IMPORT_GCAL_END](successfulResponse); + + // Spinner should disappear (importing set to false) + expect(mockDispatch).toHaveBeenCalledWith( + importGCalSlice.actions.importing(false), + ); + // Results should be set + expect(mockDispatch).toHaveBeenCalledWith( + importGCalSlice.actions.setImportResults({ + eventsCount: 25, + calendarsCount: 3, + }), + ); + // Fetch should be triggered to load new events + expect(triggerFetch).toHaveBeenCalledWith({ + reason: "IMPORT_COMPLETE", + }); + }); + + it("hides spinner when import completes successfully", () => { + const handlers: Record void> = {}; + (socket.on as jest.Mock).mockImplementation((event, handler) => { + handlers[event] = handler; + }); + + awaitingValue = true; + renderHook(() => useGcalSync()); + + // Start import + handlers[IMPORT_GCAL_START](true); + mockDispatch.mockClear(); + + // Simulate a reasonable import duration (under 30 seconds) + const REASONABLE_IMPORT_TIME_MS = 15000; + jest.advanceTimersByTime(REASONABLE_IMPORT_TIME_MS); + + // Import completes successfully + handlers[IMPORT_GCAL_END]( + JSON.stringify({ eventsCount: 100, calendarsCount: 5 }), + ); + + // Verify spinner is hidden + expect(mockDispatch).toHaveBeenCalledWith( + importGCalSlice.actions.importing(false), + ); + }); + + it("handles rapid start/end sequence without state inconsistency", () => { + const handlers: Record void> = {}; + (socket.on as jest.Mock).mockImplementation((event, handler) => { + handlers[event] = handler; + }); + + awaitingValue = true; + renderHook(() => useGcalSync()); + + // Rapid sequence: start → end (small import) + handlers[IMPORT_GCAL_START](true); + jest.advanceTimersByTime(100); // Very fast import + handlers[IMPORT_GCAL_END]( + JSON.stringify({ eventsCount: 2, calendarsCount: 1 }), + ); + + // Verify the correct sequence of actions was dispatched: + // 1. clearImportResults (on start) + // 2. importing(true) (on start) + // 3. importing(false) (on end) + // 4. setImportResults (on end) + expect(importGCalSlice.actions.clearImportResults).toHaveBeenCalledWith( + undefined, + ); + expect(importGCalSlice.actions.importing).toHaveBeenCalledWith(true); + expect(importGCalSlice.actions.importing).toHaveBeenCalledWith(false); + expect(importGCalSlice.actions.setImportResults).toHaveBeenCalledWith({ + eventsCount: 2, + calendarsCount: 1, + }); + }); + + it("handles import end with empty payload gracefully", () => { + const handlers: Record void> = {}; + (socket.on as jest.Mock).mockImplementation((event, handler) => { + handlers[event] = handler; + }); + + awaitingValue = true; + renderHook(() => useGcalSync()); + + handlers[IMPORT_GCAL_START](true); + mockDispatch.mockClear(); + + // Backend sends empty response (edge case) + handlers[IMPORT_GCAL_END](JSON.stringify({})); + + // Should still hide spinner and set empty results + expect(mockDispatch).toHaveBeenCalledWith( + importGCalSlice.actions.importing(false), + ); + expect(mockDispatch).toHaveBeenCalledWith( + importGCalSlice.actions.setImportResults({}), + ); + }); + + it("handles import end with object payload (non-string)", () => { + const handlers: Record void> = {}; + (socket.on as jest.Mock).mockImplementation((event, handler) => { + handlers[event] = handler; + }); + + awaitingValue = true; + renderHook(() => useGcalSync()); + + handlers[IMPORT_GCAL_START](true); + mockDispatch.mockClear(); + + // Backend sends object directly (alternative format) + handlers[IMPORT_GCAL_END]({ eventsCount: 50, calendarsCount: 4 }); + + expect(mockDispatch).toHaveBeenCalledWith( + importGCalSlice.actions.importing(false), + ); + expect(mockDispatch).toHaveBeenCalledWith( + importGCalSlice.actions.setImportResults({ + eventsCount: 50, + calendarsCount: 4, + }), + ); + }); + + it("sets error state when backend returns malformed JSON", () => { + const handlers: Record void> = {}; + (socket.on as jest.Mock).mockImplementation((event, handler) => { + handlers[event] = handler; + }); + const consoleErrorSpy = jest + .spyOn(console, "error") + .mockImplementation(() => {}); + + awaitingValue = true; + renderHook(() => useGcalSync()); + + handlers[IMPORT_GCAL_START](true); + mockDispatch.mockClear(); + + // Backend sends malformed response + handlers[IMPORT_GCAL_END]("not valid json {{{"); + + // Should hide spinner + expect(mockDispatch).toHaveBeenCalledWith( + importGCalSlice.actions.importing(false), + ); + // Should set error + expect(mockDispatch).toHaveBeenCalledWith( + importGCalSlice.actions.setImportError( + "Failed to parse Google Calendar import results.", + ), + ); + // Should NOT set results + expect(importGCalSlice.actions.setImportResults).not.toHaveBeenCalled(); + + consoleErrorSpy.mockRestore(); + }); }); }); diff --git a/packages/web/src/socket/hooks/useGcalSync.ts b/packages/web/src/socket/hooks/useGcalSync.ts index 3e11d73c3..1dcb91e2f 100644 --- a/packages/web/src/socket/hooks/useGcalSync.ts +++ b/packages/web/src/socket/hooks/useGcalSync.ts @@ -1,4 +1,4 @@ -import { useCallback, useEffect } from "react"; +import { useCallback, useEffect, useRef } from "react"; import { useDispatch } from "react-redux"; import { IMPORT_GCAL_END, @@ -8,10 +8,7 @@ import { import { UserMetadata } from "@core/types/user.types"; import { shouldImportGCal } from "@core/util/event/event.util"; import { Sync_AsyncStateContextReason } from "@web/ducks/events/context/sync.context"; -import { - selectAwaitingImportResults, - selectImporting, -} from "@web/ducks/events/selectors/sync.selector"; +import { selectIsImportPending } from "@web/ducks/events/selectors/sync.selector"; import { importGCalSlice, triggerFetch, @@ -21,8 +18,11 @@ import { socket } from "../client/socket.client"; export const useGcalSync = () => { const dispatch = useDispatch(); - const importing = useAppSelector(selectImporting); - const awaitingImportResults = useAppSelector(selectAwaitingImportResults); + const isImportPending = useAppSelector(selectIsImportPending); + + // Keep ref in sync synchronously during render to avoid race with socket events + const isImportPendingRef = useRef(isImportPending); + isImportPendingRef.current = isImportPending; const onImportStart = useCallback( (importing = true) => { @@ -37,7 +37,7 @@ export const useGcalSync = () => { const onImportEnd = useCallback( (payload?: { eventsCount?: number; calendarsCount?: number } | string) => { dispatch(importGCalSlice.actions.importing(false)); - if (!awaitingImportResults) { + if (!isImportPendingRef.current) { return; } @@ -69,7 +69,7 @@ export const useGcalSync = () => { }), ); }, - [dispatch, awaitingImportResults], + [dispatch], ); const onMetadataFetch = useCallback( @@ -77,7 +77,7 @@ export const useGcalSync = () => { const importGcal = shouldImportGCal(metadata); const isBackendImporting = metadata.sync?.importGCal === "importing"; - if (awaitingImportResults) { + if (isImportPendingRef.current) { if (isBackendImporting) { dispatch(importGCalSlice.actions.importing(true)); } @@ -91,7 +91,7 @@ export const useGcalSync = () => { dispatch(importGCalSlice.actions.request(undefined as never)); } }, - [dispatch, awaitingImportResults, onImportStart], + [dispatch, onImportStart], ); useEffect(() => { diff --git a/packages/web/src/socket/provider/SocketProvider.interaction.test.tsx b/packages/web/src/socket/provider/SocketProvider.interaction.test.tsx new file mode 100644 index 000000000..deeaffc92 --- /dev/null +++ b/packages/web/src/socket/provider/SocketProvider.interaction.test.tsx @@ -0,0 +1,491 @@ +import { act } from "react"; +import { Provider } from "react-redux"; +import { combineReducers, configureStore } from "@reduxjs/toolkit"; +import "@testing-library/jest-dom"; +import { render, screen, waitFor } from "@testing-library/react"; +import { + IMPORT_GCAL_END, + IMPORT_GCAL_START, +} from "@core/constants/websocket.constants"; +import { SyncEventsOverlay } from "@web/components/SyncEventsOverlay/SyncEventsOverlay"; +import { authSlice } from "@web/ducks/auth/slices/auth.slice"; +import { + importGCalSlice, + importLatestSlice, +} from "@web/ducks/events/slices/sync.slice"; +import { socket } from "./SocketProvider"; +import SocketProvider from "./SocketProvider"; + +// Mock dependencies +jest.mock("@web/auth/hooks/user/useUser", () => ({ + useUser: () => ({ userId: "test-user-id" }), +})); + +jest.mock("socket.io-client", () => ({ + io: jest.fn(() => ({ + on: jest.fn(), + once: jest.fn(), + emit: jest.fn(), + connect: jest.fn(), + disconnect: jest.fn(), + removeListener: jest.fn(), + connected: false, + })), +})); + +/** + * Integration tests for the Google Calendar re-authentication flow. + * + * These tests validate the complete user experience when re-authenticating + * after a session expires, ensuring the spinner appears during import + * and disappears correctly when the import completes or times out. + */ +describe("GCal Re-Authentication Flow", () => { + // Socket event callbacks captured during render + let importEndCallback: ((data?: string) => void) | undefined; + let importStartCallback: (() => void) | undefined; + + // Default timeout for import operations (30 seconds is reasonable for GCal sync) + const IMPORT_TIMEOUT_MS = 30_000; + + const createTestStore = (preloadedState?: { + isImportPending?: boolean; + importing?: boolean; + isAuthenticating?: boolean; + }) => { + return configureStore({ + reducer: { + sync: combineReducers({ + importGCal: importGCalSlice.reducer, + importLatest: importLatestSlice.reducer, + }), + auth: authSlice.reducer, + }, + preloadedState: { + sync: { + importGCal: { + importing: preloadedState?.importing ?? false, + importResults: null, + pendingLocalEventsSynced: null, + isImportPending: preloadedState?.isImportPending ?? false, + importError: null, + isLoading: false, + value: undefined, + error: undefined, + }, + importLatest: { + isFetchNeeded: false, + reason: null, + }, + }, + auth: { + status: preloadedState?.isAuthenticating ? "authenticating" : "idle", + error: null, + }, + }, + }); + }; + + beforeEach(() => { + jest.clearAllMocks(); + jest.useFakeTimers(); + document.body.removeAttribute("data-app-locked"); + importEndCallback = undefined; + importStartCallback = undefined; + + // Capture socket event handlers when they're registered + (socket.on as jest.Mock).mockImplementation((event, callback) => { + if (event === IMPORT_GCAL_END) { + importEndCallback = callback; + } + if (event === IMPORT_GCAL_START) { + importStartCallback = callback; + } + }); + }); + + afterEach(() => { + jest.useRealTimers(); + document.body.removeAttribute("data-app-locked"); + }); + + describe("Spinner visibility during import", () => { + it("shows spinner when user initiates re-authentication", async () => { + // User clicks "Reconnect Google Calendar" which sets isImportPending = true + const store = createTestStore({ isImportPending: true }); + + render( + + + + + , + ); + + // Spinner should be visible with import message + expect( + screen.getByText("Importing your Google Calendar events..."), + ).toBeInTheDocument(); + expect( + screen.getByText("Please hang tight while we sync your calendar"), + ).toBeInTheDocument(); + + // App should be locked (no interactions allowed) + expect(document.body.getAttribute("data-app-locked")).toBe("true"); + }); + + it("keeps spinner visible when IMPORT_GCAL_START event arrives", async () => { + const store = createTestStore({ isImportPending: true }); + + render( + + + + + , + ); + + await waitFor(() => { + expect(importStartCallback).toBeDefined(); + }); + + // Backend sends IMPORT_GCAL_START + await act(async () => { + importStartCallback?.(); + }); + + // Spinner should still be visible + expect( + screen.getByText("Importing your Google Calendar events..."), + ).toBeInTheDocument(); + + // State should now have importing = true + const state = store.getState(); + expect(state.sync.importGCal.importing).toBe(true); + }); + + it("hides spinner when import completes successfully", async () => { + const store = createTestStore({ isImportPending: true }); + + const { container } = render( + + + + + , + ); + + await waitFor(() => { + expect(importEndCallback).toBeDefined(); + }); + + // Verify spinner is initially visible + expect( + screen.getByText("Importing your Google Calendar events..."), + ).toBeInTheDocument(); + + // Backend sends IMPORT_GCAL_START then IMPORT_GCAL_END with success + await act(async () => { + importStartCallback?.(); + }); + + await act(async () => { + importEndCallback?.( + JSON.stringify({ eventsCount: 15, calendarsCount: 3 }), + ); + }); + + // Allow buffered visibility to settle + await act(async () => { + jest.advanceTimersByTime(100); + }); + + // Spinner should be hidden + expect( + screen.queryByText("Importing your Google Calendar events..."), + ).not.toBeInTheDocument(); + + // App should be unlocked + expect(container.firstChild).toBeNull(); + expect(document.body.getAttribute("data-app-locked")).toBeNull(); + + // State should reflect successful import + const state = store.getState(); + expect(state.sync.importGCal.importResults).toEqual({ + eventsCount: 15, + calendarsCount: 3, + }); + expect(state.sync.importGCal.importing).toBe(false); + expect(state.sync.importGCal.isImportPending).toBe(false); + }); + + it("hides spinner when import completes with zero events", async () => { + const store = createTestStore({ isImportPending: true }); + + render( + + + + + , + ); + + await waitFor(() => { + expect(importEndCallback).toBeDefined(); + }); + + // Backend sends IMPORT_GCAL_END with zero events (valid response) + await act(async () => { + importEndCallback?.( + JSON.stringify({ eventsCount: 0, calendarsCount: 1 }), + ); + }); + + await act(async () => { + jest.advanceTimersByTime(100); + }); + + // Spinner should be hidden even with zero events + expect( + screen.queryByText("Importing your Google Calendar events..."), + ).not.toBeInTheDocument(); + + const state = store.getState(); + expect(state.sync.importGCal.importResults).toEqual({ + eventsCount: 0, + calendarsCount: 1, + }); + }); + }); + + describe("Complete re-authentication flow", () => { + it("handles full flow: OAuth → import start → import end", async () => { + // Start with OAuth in progress (user clicked Connect/Reconnect) + const store = createTestStore({ + isAuthenticating: true, + isImportPending: true, + }); + + const { rerender } = render( + + + + + , + ); + + // Phase 1: OAuth in progress - should show sign-in message + expect( + screen.getByText("Complete Google sign-in..."), + ).toBeInTheDocument(); + + await waitFor(() => { + expect(importStartCallback).toBeDefined(); + }); + + // OAuth completes, transition to import phase + await act(async () => { + store.dispatch(authSlice.actions.resetAuth()); + }); + + rerender( + + + + + , + ); + + // Phase 2: Import in progress - should show import message + expect( + screen.getByText("Importing your Google Calendar events..."), + ).toBeInTheDocument(); + + // Backend starts import + await act(async () => { + importStartCallback?.(); + }); + + expect( + screen.getByText("Importing your Google Calendar events..."), + ).toBeInTheDocument(); + + // Backend completes import + await act(async () => { + importEndCallback?.( + JSON.stringify({ eventsCount: 42, calendarsCount: 2 }), + ); + }); + + await act(async () => { + jest.advanceTimersByTime(100); + }); + + // Phase 3: Complete - spinner should be gone + expect( + screen.queryByText("Importing your Google Calendar events..."), + ).not.toBeInTheDocument(); + expect( + screen.queryByText("Complete Google sign-in..."), + ).not.toBeInTheDocument(); + + // Verify final state + const state = store.getState(); + expect(state.sync.importGCal.importResults).toEqual({ + eventsCount: 42, + calendarsCount: 2, + }); + expect(state.sync.importLatest.isFetchNeeded).toBe(true); + }); + }); + + describe("Timeout handling", () => { + it("import should complete within sensible timeout", async () => { + const store = createTestStore({ isImportPending: true }); + + render( + + + + + , + ); + + await waitFor(() => { + expect(importEndCallback).toBeDefined(); + }); + + // Simulate time passing but import completes before timeout + await act(async () => { + jest.advanceTimersByTime(IMPORT_TIMEOUT_MS / 2); // 15 seconds + }); + + // Spinner should still be visible (waiting for backend) + expect( + screen.getByText("Importing your Google Calendar events..."), + ).toBeInTheDocument(); + + // Backend responds successfully + await act(async () => { + importEndCallback?.( + JSON.stringify({ eventsCount: 10, calendarsCount: 1 }), + ); + }); + + await act(async () => { + jest.advanceTimersByTime(100); + }); + + // Should complete successfully + expect( + screen.queryByText("Importing your Google Calendar events..."), + ).not.toBeInTheDocument(); + + const state = store.getState(); + expect(state.sync.importGCal.importResults).not.toBeNull(); + }); + + it("handles error response from backend", async () => { + const store = createTestStore({ isImportPending: true }); + + render( + + + + + , + ); + + await waitFor(() => { + expect(importEndCallback).toBeDefined(); + }); + + // Backend sends malformed JSON (error case) + const consoleSpy = jest + .spyOn(console, "error") + .mockImplementation(() => {}); + + await act(async () => { + importEndCallback?.("invalid-json-{{{"); + }); + + await act(async () => { + jest.advanceTimersByTime(100); + }); + + // Spinner should be hidden (error state also hides spinner) + expect( + screen.queryByText("Importing your Google Calendar events..."), + ).not.toBeInTheDocument(); + + // State should reflect error + const state = store.getState(); + expect(state.sync.importGCal.importError).toBe( + "Failed to parse Google Calendar import results.", + ); + expect(state.sync.importGCal.isImportPending).toBe(false); + + consoleSpy.mockRestore(); + }); + }); + + describe("Race condition handling (ref pattern)", () => { + it("processes import end correctly when state changes between renders", async () => { + // Start with isImportPending = false + const store = createTestStore({ isImportPending: false }); + + const { rerender } = render( + + + + + , + ); + + await waitFor(() => { + expect(importEndCallback).toBeDefined(); + }); + + // User clicks Reconnect - state changes to isImportPending = true + await act(async () => { + store.dispatch(importGCalSlice.actions.setIsImportPending(true)); + }); + + rerender( + + + + + , + ); + + // Spinner should now be visible + expect( + screen.getByText("Importing your Google Calendar events..."), + ).toBeInTheDocument(); + + // Event arrives - with the ref pattern fix, this should process correctly + await act(async () => { + importEndCallback?.( + JSON.stringify({ eventsCount: 25, calendarsCount: 4 }), + ); + }); + + await act(async () => { + jest.advanceTimersByTime(100); + }); + + // Spinner should be hidden (fix prevents stale closure issue) + expect( + screen.queryByText("Importing your Google Calendar events..."), + ).not.toBeInTheDocument(); + + // Results should be set correctly + const state = store.getState(); + expect(state.sync.importGCal.importResults).toEqual({ + eventsCount: 25, + calendarsCount: 4, + }); + }); + }); +}); diff --git a/packages/web/src/socket/provider/SocketProvider.test.tsx b/packages/web/src/socket/provider/SocketProvider.test.tsx index 00a5e7e3e..dc9f15cbc 100644 --- a/packages/web/src/socket/provider/SocketProvider.test.tsx +++ b/packages/web/src/socket/provider/SocketProvider.test.tsx @@ -1,3 +1,4 @@ +import { act } from "react"; import { Provider } from "react-redux"; import { combineReducers, configureStore } from "@reduxjs/toolkit"; import { render, waitFor } from "@testing-library/react"; @@ -60,7 +61,7 @@ describe("SocketProvider", () => { }, }); - store.dispatch(importGCalSlice.actions.setAwaitingImportResults(true)); + store.dispatch(importGCalSlice.actions.setIsImportPending(true)); render( @@ -74,8 +75,15 @@ describe("SocketProvider", () => { expect(importEndCallback).toBeDefined(); }); - importStartCallback?.(); - importEndCallback?.(JSON.stringify({ eventsCount: 10, calendarsCount: 2 })); + await act(async () => { + importStartCallback?.(); + }); + + await act(async () => { + importEndCallback?.( + JSON.stringify({ eventsCount: 10, calendarsCount: 2 }), + ); + }); const state = store.getState(); expect(state.sync.importGCal.importResults).toEqual({ @@ -83,7 +91,7 @@ describe("SocketProvider", () => { calendarsCount: 2, }); expect(state.sync.importGCal.importing).toBe(false); - expect(state.sync.importGCal.awaitingImportResults).toBe(false); + expect(state.sync.importGCal.isImportPending).toBe(false); expect(state.sync.importLatest.isFetchNeeded).toBe(true); });