From da23a45a4babb0a8ac58c0fdaaa5126d7554a84e Mon Sep 17 00:00:00 2001 From: Tyler Dane Date: Fri, 27 Feb 2026 13:40:37 -0800 Subject: [PATCH 01/23] refactor(issue-templates): simplify feature request and bug report templates - Removed unnecessary emoji from template names for a cleaner presentation. - Eliminated the priority dropdown from the feature request template to streamline the submission process. - Updated the bug report template to remove the emoji, enhancing consistency across issue templates. - Adjusted the configuration file to reflect these changes, ensuring clarity in the issue submission process. --- .github/ISSUE_TEMPLATE/1-feature-request.yml | 13 +------------ .github/ISSUE_TEMPLATE/2-bug-report.yml | 2 +- .github/ISSUE_TEMPLATE/config.yml | 4 ++-- 3 files changed, 4 insertions(+), 15 deletions(-) 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 From c595d6ba61c9d5d0eff3c6187bb47169c646c3eb Mon Sep 17 00:00:00 2001 From: Tyler Dane Date: Fri, 27 Feb 2026 14:00:49 -0800 Subject: [PATCH 02/23] test(socket): enhance SocketProvider tests with async act calls - Updated SocketProvider tests to utilize `act` for asynchronous callback invocations, ensuring proper handling of state updates during testing. - Improved test reliability by wrapping callback executions in `act`, aligning with React's testing best practices. --- .../web/src/socket/provider/SocketProvider.test.tsx | 12 ++++++++++-- 1 file changed, 10 insertions(+), 2 deletions(-) diff --git a/packages/web/src/socket/provider/SocketProvider.test.tsx b/packages/web/src/socket/provider/SocketProvider.test.tsx index 00a5e7e3e..b20dcdbb6 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"; @@ -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({ From c23bdf74dfccd67f31750d45240f2256ba53edc5 Mon Sep 17 00:00:00 2001 From: Tyler Dane Date: Fri, 27 Feb 2026 14:01:56 -0800 Subject: [PATCH 03/23] test(socket): add race condition handling test for useGcalSync - Introduced a new test case to verify the correct handling of import end events when the awaitingImportResults state changes mid-render. - Utilized a ref to prevent stale closures, ensuring that the correct state is referenced during socket event processing. - Enhanced the reliability of the useGcalSync hook by ensuring it properly processes events even when state changes occur asynchronously. --- .../web/src/socket/hooks/useGcalSync.test.ts | 33 +++++++++++++++++++ packages/web/src/socket/hooks/useGcalSync.ts | 23 +++++++------ 2 files changed, 46 insertions(+), 10 deletions(-) diff --git a/packages/web/src/socket/hooks/useGcalSync.test.ts b/packages/web/src/socket/hooks/useGcalSync.test.ts index b4149ab21..ed467e010 100644 --- a/packages/web/src/socket/hooks/useGcalSync.test.ts +++ b/packages/web/src/socket/hooks/useGcalSync.test.ts @@ -157,5 +157,38 @@ 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; + (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, + }), + ); + }); }); }); diff --git a/packages/web/src/socket/hooks/useGcalSync.ts b/packages/web/src/socket/hooks/useGcalSync.ts index 3e11d73c3..735b5bb0c 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 { selectAwaitingImportResults } from "@web/ducks/events/selectors/sync.selector"; import { importGCalSlice, triggerFetch, @@ -21,9 +18,15 @@ import { socket } from "../client/socket.client"; export const useGcalSync = () => { const dispatch = useDispatch(); - const importing = useAppSelector(selectImporting); const awaitingImportResults = useAppSelector(selectAwaitingImportResults); + // Use ref to prevent stale closures when socket events arrive + // between state change and effect re-run + const awaitingImportResultsRef = useRef(awaitingImportResults); + useEffect(() => { + awaitingImportResultsRef.current = awaitingImportResults; + }, [awaitingImportResults]); + const onImportStart = useCallback( (importing = true) => { if (importing) { @@ -37,7 +40,7 @@ export const useGcalSync = () => { const onImportEnd = useCallback( (payload?: { eventsCount?: number; calendarsCount?: number } | string) => { dispatch(importGCalSlice.actions.importing(false)); - if (!awaitingImportResults) { + if (!awaitingImportResultsRef.current) { return; } @@ -69,7 +72,7 @@ export const useGcalSync = () => { }), ); }, - [dispatch, awaitingImportResults], + [dispatch], ); const onMetadataFetch = useCallback( @@ -77,7 +80,7 @@ export const useGcalSync = () => { const importGcal = shouldImportGCal(metadata); const isBackendImporting = metadata.sync?.importGCal === "importing"; - if (awaitingImportResults) { + if (awaitingImportResultsRef.current) { if (isBackendImporting) { dispatch(importGCalSlice.actions.importing(true)); } @@ -91,7 +94,7 @@ export const useGcalSync = () => { dispatch(importGCalSlice.actions.request(undefined as never)); } }, - [dispatch, awaitingImportResults, onImportStart], + [dispatch, onImportStart], ); useEffect(() => { From c8c6415b6ef015e05ea91aefc2b1f548829c8ea1 Mon Sep 17 00:00:00 2001 From: Tyler Dane Date: Fri, 27 Feb 2026 14:06:14 -0800 Subject: [PATCH 04/23] delete(svg): remove unused circle.svg file - Deleted the circle.svg file from the public SVG assets as it is no longer needed in the project. - This cleanup helps streamline the asset management and reduces unnecessary file clutter. --- packages/web/src/public/svg/circle.svg | 5 - .../web/src/socket/hooks/useGcalSync.test.ts | 204 ++++++++++++++++++ 2 files changed, 204 insertions(+), 5 deletions(-) delete mode 100644 packages/web/src/public/svg/circle.svg 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/hooks/useGcalSync.test.ts b/packages/web/src/socket/hooks/useGcalSync.test.ts index ed467e010..c910b6f00 100644 --- a/packages/web/src/socket/hooks/useGcalSync.test.ts +++ b/packages/web/src/socket/hooks/useGcalSync.test.ts @@ -57,6 +57,7 @@ describe("useGcalSync", () => { beforeEach(() => { jest.clearAllMocks(); + jest.useFakeTimers(); importingValue = false; awaitingValue = false; (useDispatch as jest.Mock).mockReturnValue(mockDispatch); @@ -71,6 +72,10 @@ describe("useGcalSync", () => { }); }); + afterEach(() => { + jest.useRealTimers(); + }); + it("sets up socket listeners", () => { renderHook(() => useGcalSync()); @@ -191,4 +196,203 @@ describe("useGcalSync", () => { ); }); }); + + 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 within timeout", () => { + 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 }), + ); + + // Final state should have importing=false + const importingCalls = mockDispatch.mock.calls.filter( + (call) => + call[0] === importGCalSlice.actions.importing(true) || + call[0] === importGCalSlice.actions.importing(false), + ); + + // Last importing call should be false (spinner hidden) + expect(mockDispatch).toHaveBeenLastCalledWith( + importGCalSlice.actions.setImportResults({ + 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(); + }); + }); }); From 958fb6e841d0b7c4992d7430e39bb00b2bc3ff42 Mon Sep 17 00:00:00 2001 From: Tyler Dane Date: Fri, 27 Feb 2026 14:15:38 -0800 Subject: [PATCH 05/23] fix(socket): address PR review comments for useGcalSync - Update ref synchronously during render instead of in useEffect to fully eliminate race condition window - Rename misleading test name (timeout -> successfully) - Fix test assertions to validate action creator calls directly instead of using unused variable and dispatch-based assertions Co-Authored-By: Claude Opus 4.5 --- .../web/src/socket/hooks/useGcalSync.test.ts | 28 +++++++++---------- packages/web/src/socket/hooks/useGcalSync.ts | 7 ++--- 2 files changed, 16 insertions(+), 19 deletions(-) diff --git a/packages/web/src/socket/hooks/useGcalSync.test.ts b/packages/web/src/socket/hooks/useGcalSync.test.ts index c910b6f00..cab41bea6 100644 --- a/packages/web/src/socket/hooks/useGcalSync.test.ts +++ b/packages/web/src/socket/hooks/useGcalSync.test.ts @@ -251,7 +251,7 @@ describe("useGcalSync", () => { }); }); - it("hides spinner when import completes within timeout", () => { + it("hides spinner when import completes successfully", () => { const handlers: Record void> = {}; (socket.on as jest.Mock).mockImplementation((event, handler) => { handlers[event] = handler; @@ -295,20 +295,20 @@ describe("useGcalSync", () => { JSON.stringify({ eventsCount: 2, calendarsCount: 1 }), ); - // Final state should have importing=false - const importingCalls = mockDispatch.mock.calls.filter( - (call) => - call[0] === importGCalSlice.actions.importing(true) || - call[0] === importGCalSlice.actions.importing(false), - ); - - // Last importing call should be false (spinner hidden) - expect(mockDispatch).toHaveBeenLastCalledWith( - importGCalSlice.actions.setImportResults({ - 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", () => { diff --git a/packages/web/src/socket/hooks/useGcalSync.ts b/packages/web/src/socket/hooks/useGcalSync.ts index 735b5bb0c..e7bb0770f 100644 --- a/packages/web/src/socket/hooks/useGcalSync.ts +++ b/packages/web/src/socket/hooks/useGcalSync.ts @@ -20,12 +20,9 @@ export const useGcalSync = () => { const dispatch = useDispatch(); const awaitingImportResults = useAppSelector(selectAwaitingImportResults); - // Use ref to prevent stale closures when socket events arrive - // between state change and effect re-run + // Keep ref in sync synchronously during render to avoid race with socket events const awaitingImportResultsRef = useRef(awaitingImportResults); - useEffect(() => { - awaitingImportResultsRef.current = awaitingImportResults; - }, [awaitingImportResults]); + awaitingImportResultsRef.current = awaitingImportResults; const onImportStart = useCallback( (importing = true) => { From f0894e79c77ed186e552c45ebfe34c3d9df696ee Mon Sep 17 00:00:00 2001 From: Tyler Dane Date: Fri, 27 Feb 2026 14:25:27 -0800 Subject: [PATCH 06/23] refactor(sync): rename awaitingImportResults to isImportPending - Updated state management and selectors to reflect the new naming convention for clarity. - Adjusted related components and tests to ensure consistency with the updated state name. - This change enhances code readability and aligns with the overall naming strategy in the project. --- .../web/src/__tests__/utils/state/store.test.util.ts | 2 +- .../SyncEventsOverlay/SyncEventsOverlay.test.tsx | 4 ++-- .../SyncEventsOverlay/SyncEventsOverlay.tsx | 4 ++-- .../web/src/ducks/events/selectors/sync.selector.ts | 4 ++-- packages/web/src/ducks/events/slices/sync.slice.ts | 10 +++++----- packages/web/src/socket/hooks/useGcalSync.test.ts | 4 ++-- packages/web/src/socket/hooks/useGcalSync.ts | 12 ++++++------ .../web/src/socket/provider/SocketProvider.test.tsx | 2 +- 8 files changed, 21 insertions(+), 21 deletions(-) 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/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..ae97bfec2 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,7 +39,7 @@ export const importGCalSlice = createAsyncSlice< importing: false, importResults: null, pendingLocalEventsSynced: null, - awaitingImportResults: false, + isImportPending: false, importError: null, }, reducers: { @@ -47,7 +47,7 @@ export const importGCalSlice = createAsyncSlice< state.importing = action.payload; }, setAwaitingImportResults: (state, action: PayloadAction) => { - state.awaitingImportResults = action.payload; + 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/socket/hooks/useGcalSync.test.ts b/packages/web/src/socket/hooks/useGcalSync.test.ts index cab41bea6..955a75a0b 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, @@ -65,7 +65,7 @@ describe("useGcalSync", () => { if (selector === selectImporting) { return importingValue; } - if (selector === selectAwaitingImportResults) { + if (selector === selectIsImportPending) { return awaitingValue; } return false; diff --git a/packages/web/src/socket/hooks/useGcalSync.ts b/packages/web/src/socket/hooks/useGcalSync.ts index e7bb0770f..1dcb91e2f 100644 --- a/packages/web/src/socket/hooks/useGcalSync.ts +++ b/packages/web/src/socket/hooks/useGcalSync.ts @@ -8,7 +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 } from "@web/ducks/events/selectors/sync.selector"; +import { selectIsImportPending } from "@web/ducks/events/selectors/sync.selector"; import { importGCalSlice, triggerFetch, @@ -18,11 +18,11 @@ import { socket } from "../client/socket.client"; export const useGcalSync = () => { const dispatch = useDispatch(); - const awaitingImportResults = useAppSelector(selectAwaitingImportResults); + const isImportPending = useAppSelector(selectIsImportPending); // Keep ref in sync synchronously during render to avoid race with socket events - const awaitingImportResultsRef = useRef(awaitingImportResults); - awaitingImportResultsRef.current = awaitingImportResults; + 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 (!awaitingImportResultsRef.current) { + if (!isImportPendingRef.current) { return; } @@ -77,7 +77,7 @@ export const useGcalSync = () => { const importGcal = shouldImportGCal(metadata); const isBackendImporting = metadata.sync?.importGCal === "importing"; - if (awaitingImportResultsRef.current) { + if (isImportPendingRef.current) { if (isBackendImporting) { dispatch(importGCalSlice.actions.importing(true)); } diff --git a/packages/web/src/socket/provider/SocketProvider.test.tsx b/packages/web/src/socket/provider/SocketProvider.test.tsx index b20dcdbb6..ad33e1964 100644 --- a/packages/web/src/socket/provider/SocketProvider.test.tsx +++ b/packages/web/src/socket/provider/SocketProvider.test.tsx @@ -91,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); }); From 776cc9e966e0c35d19ce4627b9912d8ea92c12e0 Mon Sep 17 00:00:00 2001 From: Tyler Dane Date: Fri, 27 Feb 2026 14:30:42 -0800 Subject: [PATCH 07/23] refactor(socket): simplify reconnect logic and update test case - Removed the 1-second delay in the reconnect function, allowing for immediate reconnection after disconnection. - Updated the corresponding test case to reflect the change in behavior, ensuring it accurately tests the immediate reconnection functionality. - This change enhances the responsiveness of the socket client and improves the clarity of the test case. --- packages/web/src/socket/client/socket.client.test.ts | 6 +----- packages/web/src/socket/client/socket.client.ts | 6 +----- 2 files changed, 2 insertions(+), 10 deletions(-) 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) => { From 30cd79d9128f4594c5c08101f3f9c6879689f13a Mon Sep 17 00:00:00 2001 From: Tyler Dane Date: Fri, 27 Feb 2026 14:41:49 -0800 Subject: [PATCH 08/23] refactor(tests): update useGcalSync tests to remove fake timers - Removed the use of fake timers in the tests for useGcalSync, simplifying the test setup. - Updated type definitions for event handler variables to allow for undefined values, enhancing type safety. - This change improves test clarity and aligns with best practices for handling asynchronous events in React testing. --- packages/web/src/socket/hooks/useGcalSync.test.ts | 13 ++++--------- 1 file changed, 4 insertions(+), 9 deletions(-) diff --git a/packages/web/src/socket/hooks/useGcalSync.test.ts b/packages/web/src/socket/hooks/useGcalSync.test.ts index 955a75a0b..6331a07fc 100644 --- a/packages/web/src/socket/hooks/useGcalSync.test.ts +++ b/packages/web/src/socket/hooks/useGcalSync.test.ts @@ -57,7 +57,6 @@ describe("useGcalSync", () => { beforeEach(() => { jest.clearAllMocks(); - jest.useFakeTimers(); importingValue = false; awaitingValue = false; (useDispatch as jest.Mock).mockReturnValue(mockDispatch); @@ -72,10 +71,6 @@ describe("useGcalSync", () => { }); }); - afterEach(() => { - jest.useRealTimers(); - }); - it("sets up socket listeners", () => { renderHook(() => useGcalSync()); @@ -92,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; @@ -116,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; @@ -146,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; @@ -167,7 +162,7 @@ describe("useGcalSync", () => { // Simulate the race condition: starts false, changes to true, // then event arrives (testing ref pattern works correctly) - 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; From 7270fc74506f43c4de4603cf3b1db78130fa7fcc Mon Sep 17 00:00:00 2001 From: Tyler Dane Date: Fri, 27 Feb 2026 14:43:31 -0800 Subject: [PATCH 09/23] test(socket): add integration tests for Google Calendar re-authentication flow - Introduced a new test suite for the Google Calendar re-authentication process, validating user experience during import operations. - Implemented tests to ensure the spinner visibility during import initiation, handling of socket events, and correct state updates upon import completion. - Enhanced test reliability by utilizing `act` for asynchronous operations and capturing socket event callbacks. - This addition improves coverage for the re-authentication flow and ensures a seamless user experience during Google Calendar synchronization. --- .../SocketProvider.interaction.test.tsx | 491 ++++++++++++++++++ 1 file changed, 491 insertions(+) create mode 100644 packages/web/src/socket/provider/SocketProvider.interaction.test.tsx 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..8ea6c4fdd --- /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.setAwaitingImportResults(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, + }); + }); + }); +}); From b1a6c4015a4cd764c589e08f5dd0a55c731f6981 Mon Sep 17 00:00:00 2001 From: Tyler Dane Date: Fri, 27 Feb 2026 14:48:03 -0800 Subject: [PATCH 10/23] refactor(sync): rename setAwaitingImportResults to setIsImportPending - Updated the Redux action and corresponding function names to improve clarity and consistency in the import state management. - Adjusted all related components and tests to reflect the new naming convention, ensuring seamless integration across the codebase. - This change enhances code readability and aligns with the overall naming strategy in the project. --- e2e/utils/oauth-test-utils.ts | 8 ++++---- .../web/src/auth/hooks/oauth/useGoogleAuth.test.ts | 8 ++++---- packages/web/src/auth/hooks/oauth/useGoogleAuth.ts | 10 +++++----- packages/web/src/ducks/events/slices/sync.slice.ts | 2 +- .../provider/SocketProvider.interaction.test.tsx | 2 +- .../web/src/socket/provider/SocketProvider.test.tsx | 2 +- 6 files changed, 16 insertions(+), 16 deletions(-) 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/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/ducks/events/slices/sync.slice.ts b/packages/web/src/ducks/events/slices/sync.slice.ts index ae97bfec2..a2e8575ba 100644 --- a/packages/web/src/ducks/events/slices/sync.slice.ts +++ b/packages/web/src/ducks/events/slices/sync.slice.ts @@ -46,7 +46,7 @@ export const importGCalSlice = createAsyncSlice< importing: (state, action: PayloadAction) => { state.importing = action.payload; }, - setAwaitingImportResults: (state, action: PayloadAction) => { + setIsImportPending: (state, action: PayloadAction) => { state.isImportPending = action.payload; if (action.payload) { state.importError = null; diff --git a/packages/web/src/socket/provider/SocketProvider.interaction.test.tsx b/packages/web/src/socket/provider/SocketProvider.interaction.test.tsx index 8ea6c4fdd..deeaffc92 100644 --- a/packages/web/src/socket/provider/SocketProvider.interaction.test.tsx +++ b/packages/web/src/socket/provider/SocketProvider.interaction.test.tsx @@ -448,7 +448,7 @@ describe("GCal Re-Authentication Flow", () => { // User clicks Reconnect - state changes to isImportPending = true await act(async () => { - store.dispatch(importGCalSlice.actions.setAwaitingImportResults(true)); + store.dispatch(importGCalSlice.actions.setIsImportPending(true)); }); rerender( diff --git a/packages/web/src/socket/provider/SocketProvider.test.tsx b/packages/web/src/socket/provider/SocketProvider.test.tsx index ad33e1964..dc9f15cbc 100644 --- a/packages/web/src/socket/provider/SocketProvider.test.tsx +++ b/packages/web/src/socket/provider/SocketProvider.test.tsx @@ -61,7 +61,7 @@ describe("SocketProvider", () => { }, }); - store.dispatch(importGCalSlice.actions.setAwaitingImportResults(true)); + store.dispatch(importGCalSlice.actions.setIsImportPending(true)); render( From a197200012aeb0098eb1ea1a18e3ab46174b7b71 Mon Sep 17 00:00:00 2001 From: Tyler Dane Date: Fri, 27 Feb 2026 19:05:39 -0800 Subject: [PATCH 11/23] feat(errors): enhance Google token error handling and add corresponding tests - Updated the error handling for invalid Google tokens to prune user data and notify clients via WebSocket. - Modified the response to include a specific payload indicating Google access has been revoked. - Added unit tests to verify the new error handling behavior, ensuring proper response and state management during Google token invalidation. - This change improves user experience by providing clearer feedback on session status and data management. --- .../errors/handlers/error.express.handler.ts | 17 ++++++---- .../errors/handlers/error.handler.test.ts | 34 +++++++++++++++++++ 2 files changed, 45 insertions(+), 6 deletions(-) diff --git a/packages/backend/src/common/errors/handlers/error.express.handler.ts b/packages/backend/src/common/errors/handlers/error.express.handler.ts index 1ab3fa39d..8a1650a8a 100644 --- a/packages/backend/src/common/errors/handlers/error.express.handler.ts +++ b/packages/backend/src/common/errors/handlers/error.express.handler.ts @@ -1,6 +1,7 @@ import { Request } from "express"; import { GaxiosError } from "gaxios"; import { SessionRequest } from "supertokens-node/framework/express"; +import { GOOGLE_REVOKED } from "@core/constants/websocket.constants"; import { BaseError } from "@core/errors/errors.base"; import { Status } from "@core/errors/status.codes"; import { Logger } from "@core/logger/winston.logger"; @@ -19,6 +20,7 @@ import { } from "@backend/common/services/gcal/gcal.utils"; import { CompassError, Info_Error } from "@backend/common/types/error.types"; import { SessionResponse } from "@backend/common/types/express.types"; +import { webSocketServer } from "@backend/servers/websocket/websocket.server"; import { getSyncByToken } from "@backend/sync/util/sync.queries"; import { findCompassUserBy } from "@backend/user/queries/user.queries"; import userService from "@backend/user/services/user.service"; @@ -104,20 +106,23 @@ export const handleExpressError = async ( }; const handleGoogleError = async ( - req: Request | SessionRequest, + _req: Request | SessionRequest, res: SessionResponse, userId: string, e: GaxiosError, ) => { if (isInvalidGoogleToken(e)) { - await req.session?.revokeSession(); + await userService.pruneGoogleData(userId); + webSocketServer.handleGoogleRevoked(userId); - // revoke specific sessions for this user - logger.debug( - `Invalid Google token for user: ${userId}\n\tsession revoked as result`, + logger.warn( + `Invalid Google token for user: ${userId}. Google data pruned and client notified.`, ); - res.status(Status.UNAUTHORIZED).send(); + res.status(Status.UNAUTHORIZED).send({ + code: GOOGLE_REVOKED, + message: "Google access revoked. Your Google data has been removed.", + }); return; } diff --git a/packages/backend/src/common/errors/handlers/error.handler.test.ts b/packages/backend/src/common/errors/handlers/error.handler.test.ts index 74bf83d83..b3a8ac788 100644 --- a/packages/backend/src/common/errors/handlers/error.handler.test.ts +++ b/packages/backend/src/common/errors/handlers/error.handler.test.ts @@ -1,10 +1,16 @@ +import { GOOGLE_REVOKED } from "@core/constants/websocket.constants"; import { BaseError } from "@core/errors/errors.base"; import { Status } from "@core/errors/status.codes"; +import { invalidGrant400Error } from "@backend/__tests__/mocks.gcal/errors/error.google.invalidGrant"; +import { handleExpressError } from "@backend/common/errors/handlers/error.express.handler"; import { error, + errorHandler, toClientErrorPayload, } from "@backend/common/errors/handlers/error.handler"; import { UserError } from "@backend/common/errors/user/user.errors"; +import { webSocketServer } from "@backend/servers/websocket/websocket.server"; +import userService from "@backend/user/services/user.service"; describe("error.handler", () => { describe("toClientErrorPayload", () => { @@ -38,4 +44,32 @@ describe("error.handler", () => { expect(Object.keys(payload)).toEqual(["result", "message"]); }); }); + + describe("handleExpressError", () => { + it("returns 401 with GOOGLE_REVOKED payload when Google token is invalid", async () => { + const userId = "507f1f77bcf86cd799439011"; + jest.spyOn(userService, "pruneGoogleData").mockResolvedValue(); + jest.spyOn(webSocketServer, "handleGoogleRevoked"); + jest.spyOn(errorHandler, "isOperational").mockReturnValue(true); + + const send = jest.fn(); + const res = { + header: jest.fn().mockReturnThis(), + status: jest.fn().mockReturnThis(), + send, + } as unknown as Parameters[1]; + const req = { + session: { getUserId: () => userId }, + } as Parameters[0]; + (res as { req?: typeof req }).req = req; + + await handleExpressError(req, res, invalidGrant400Error); + + expect(res.status).toHaveBeenCalledWith(Status.UNAUTHORIZED); + expect(send).toHaveBeenCalledWith({ + code: GOOGLE_REVOKED, + message: "Google access revoked. Your Google data has been removed.", + }); + }); + }); }); From 9fad698b281fd2e9d51e821ec5201e8dcf7d7d91 Mon Sep 17 00:00:00 2001 From: Tyler Dane Date: Fri, 27 Feb 2026 19:05:52 -0800 Subject: [PATCH 12/23] feat(user): implement pruneGoogleData method and corresponding tests - Added the pruneGoogleData method to UserService, which stops Google Calendar sync and removes the Google field from the user document. - Introduced a new test suite for pruneGoogleData, verifying that user data is correctly pruned and associated events are deleted. - This enhancement improves data management and user experience by ensuring that outdated Google data is effectively removed. --- .../src/user/services/user.service.test.ts | 26 +++++++++++++++++++ .../backend/src/user/services/user.service.ts | 6 +++++ 2 files changed, 32 insertions(+) diff --git a/packages/backend/src/user/services/user.service.test.ts b/packages/backend/src/user/services/user.service.test.ts index b4e5e95bb..09fc2a9fb 100644 --- a/packages/backend/src/user/services/user.service.test.ts +++ b/packages/backend/src/user/services/user.service.test.ts @@ -199,6 +199,32 @@ describe("UserService", () => { }); }); + describe("pruneGoogleData", () => { + it("stops sync and removes google field from user document", async () => { + const user = await UserDriver.createUser(); + const userId = user._id.toString(); + + expect(user.google).toBeDefined(); + + await userService.startGoogleCalendarSync(userId); + + const eventCountBefore = await mongoService.event.countDocuments({ + user: userId, + }); + expect(eventCountBefore).toBeGreaterThan(0); + + await userService.pruneGoogleData(userId); + + const storedUser = await mongoService.user.findOne({ _id: user._id }); + expect(storedUser?.google).toBeUndefined(); + + expect(await mongoService.event.countDocuments({ user: userId })).toBe(0); + expect(await mongoService.watch.countDocuments({ user: userId })).toBe(0); + const sync = await mongoService.sync.findOne({ user: userId }); + expect(sync).not.toHaveProperty(CalendarProvider.GOOGLE); + }); + }); + describe("restartGoogleCalendarSync", () => { it("restarts the import workflow and completes successfully", async () => { const { user } = await UtilDriver.setupTestUser(); diff --git a/packages/backend/src/user/services/user.service.ts b/packages/backend/src/user/services/user.service.ts index 17c88b032..9c6c7b6fa 100644 --- a/packages/backend/src/user/services/user.service.ts +++ b/packages/backend/src/user/services/user.service.ts @@ -166,6 +166,12 @@ class UserService { await syncService.deleteByIntegration("google", userId); }; + pruneGoogleData = async (userId: string): Promise => { + const _id = zObjectId.parse(userId); + await this.stopGoogleCalendarSync(userId); + await mongoService.user.updateOne({ _id }, { $unset: { google: "" } }); + }; + startGoogleCalendarSync = async ( user: string, ): Promise<{ eventsCount: number; calendarsCount: number }> => { From 5893bb0d1c5661045e19516135fcbdf80ccc0e69 Mon Sep 17 00:00:00 2001 From: Tyler Dane Date: Fri, 27 Feb 2026 19:06:31 -0800 Subject: [PATCH 13/23] feat(sync): implement Google revoked access handling and notifications - Added functionality to handle Google access revocation by pruning user data and notifying clients via WebSocket. - Updated SyncController to respond with a structured message when access is revoked, improving user feedback. - Introduced tests to verify the new behavior, ensuring proper handling of revoked access scenarios. - This enhancement improves data management and user experience by effectively managing Google Calendar access changes. --- .../src/common/services/gcal/gcal.utils.ts | 13 +-- .../src/event/controllers/event.controller.ts | 90 ++++++++----------- .../src/servers/websocket/websocket.server.ts | 5 ++ .../sync/controllers/sync.controller.test.ts | 57 +++++++++++- .../src/sync/controllers/sync.controller.ts | 12 ++- .../core/src/constants/websocket.constants.ts | 2 + packages/core/src/types/websocket.types.ts | 1 + 7 files changed, 119 insertions(+), 61 deletions(-) diff --git a/packages/backend/src/common/services/gcal/gcal.utils.ts b/packages/backend/src/common/services/gcal/gcal.utils.ts index 20ee0064f..d570bd39f 100644 --- a/packages/backend/src/common/services/gcal/gcal.utils.ts +++ b/packages/backend/src/common/services/gcal/gcal.utils.ts @@ -43,15 +43,18 @@ export const getEmailFromUrl = (url: string) => { // occurs when token expired or revoked export const isInvalidGoogleToken = (e: GaxiosError | Error) => { - const is400 = "code" in e && e.code === "400"; - const hasInvalidMsg = "message" in e && e.message === "invalid_grant"; - const isInvalid = is400 && hasInvalidMsg; + if (!isGoogleError(e)) return false; - return isGoogleError(e) && isInvalid; + const err = e as GaxiosError; + const is400 = err.code === "400" || err.response?.status === 400; + const hasInvalidMsg = err.message === "invalid_grant"; + const hasInvalidData = err.response?.data?.error === "invalid_grant"; + + return is400 && (hasInvalidMsg || hasInvalidData); }; export const isGoogleError = (e: unknown) => { - return e instanceof GaxiosError; + return e instanceof GaxiosError || (e as any)?.name === "GaxiosError"; }; export const isFullSyncRequired = (e: GaxiosError | Error) => { diff --git a/packages/backend/src/event/controllers/event.controller.ts b/packages/backend/src/event/controllers/event.controller.ts index 7d8dddf4b..e561afca5 100644 --- a/packages/backend/src/event/controllers/event.controller.ts +++ b/packages/backend/src/event/controllers/event.controller.ts @@ -38,51 +38,45 @@ class EventController { req: SReqBody, res: Res_Promise, ) => { - try { - const { body } = req; - const user = req.session?.getUserId() as string; + const { body } = req; + const user = req.session?.getUserId() as string; - // Handle both single object and array cases - const events = Array.isArray(body) ? body : [body]; + // Handle both single object and array cases + const events = Array.isArray(body) ? body : [body]; - await this.processEvents( + res.promise( + this.processEvents( events.map((e) => ({ payload: { ...e, user }, status: CompassEventStatus.CONFIRMED, applyTo: RecurringEventUpdateScope.THIS_EVENT, })) as CompassEvent[], - ); - - res.status(Status.NO_CONTENT).send(); - } catch (e) { - logger.error(e); - - res.status(Status.BAD_REQUEST).send(); - } + ).then(() => ({ statusCode: Status.NO_CONTENT })), + ); }; delete = async (req: SessionRequest, res: Res_Promise) => { - try { - const { query } = req; - const user = req.session?.getUserId() as string; - const _id = req.params["id"] as string; - const event = await eventService.readById(user, _id); - const applyTo = query["applyTo"] ?? RecurringEventUpdateScope.THIS_EVENT; - - await this.processEvents([ - { - payload: event as CompassThisEvent["payload"], - status: CompassEventStatus.CANCELLED, - applyTo: applyTo as RecurringEventUpdateScope.THIS_EVENT, - }, - ]); - - res.status(Status.NO_CONTENT).send(); - } catch (e) { - logger.error(e); - - res.status(Status.BAD_REQUEST).send(); - } + const { query } = req; + const user = req.session?.getUserId() as string; + const _id = req.params["id"] as string; + + res.promise( + eventService + .readById(user, _id) + .then((event) => { + const applyTo = + query["applyTo"] ?? RecurringEventUpdateScope.THIS_EVENT; + + return this.processEvents([ + { + payload: event as CompassThisEvent["payload"], + status: CompassEventStatus.CANCELLED, + applyTo: applyTo as RecurringEventUpdateScope.THIS_EVENT, + }, + ]); + }) + .then(() => ({ statusCode: Status.NO_CONTENT })), + ); }; deleteAllByUser = async (req: SessionRequest, res: Res_Promise) => { @@ -138,27 +132,21 @@ class EventController { }; update = async (req: SReqBody, res: Res_Promise) => { - try { - const { body, query, params, session } = req; - const user = session?.getUserId() as string; - const _id = params["id"] as string; - const payload = { ...body, user, _id } as CompassThisEvent["payload"]; - const applyTo = query["applyTo"] as RecurringEventUpdateScope.THIS_EVENT; - - await this.processEvents([ + const { body, query, params, session } = req; + const user = session?.getUserId() as string; + const _id = params["id"] as string; + const payload = { ...body, user, _id } as CompassThisEvent["payload"]; + const applyTo = query["applyTo"] as RecurringEventUpdateScope.THIS_EVENT; + + res.promise( + this.processEvents([ { payload, status: CompassEventStatus.CONFIRMED, applyTo: applyTo ?? RecurringEventUpdateScope.THIS_EVENT, }, - ]); - - res.status(Status.NO_CONTENT).send(); - } catch (e) { - logger.error(e); - - res.status(Status.BAD_REQUEST).send(); - } + ]).then(() => ({ statusCode: Status.NO_CONTENT })), + ); }; } diff --git a/packages/backend/src/servers/websocket/websocket.server.ts b/packages/backend/src/servers/websocket/websocket.server.ts index 84cbbf13b..3fd6daa56 100644 --- a/packages/backend/src/servers/websocket/websocket.server.ts +++ b/packages/backend/src/servers/websocket/websocket.server.ts @@ -6,6 +6,7 @@ import { EVENT_CHANGED, EVENT_CHANGE_PROCESSED, FETCH_USER_METADATA, + GOOGLE_REVOKED, IMPORT_GCAL_END, IMPORT_GCAL_START, RESULT_IGNORED, @@ -236,6 +237,10 @@ class WebSocketServer { handleBackgroundSomedayChange(userId: string) { return this.notifyUser(userId, SOMEDAY_EVENT_CHANGED); } + + handleGoogleRevoked(userId: string) { + return this.notifyUser(userId, GOOGLE_REVOKED); + } } export const webSocketServer = new WebSocketServer(); diff --git a/packages/backend/src/sync/controllers/sync.controller.test.ts b/packages/backend/src/sync/controllers/sync.controller.test.ts index 61836dcaf..be66b69c7 100644 --- a/packages/backend/src/sync/controllers/sync.controller.test.ts +++ b/packages/backend/src/sync/controllers/sync.controller.test.ts @@ -3,7 +3,10 @@ import { randomUUID } from "node:crypto"; import { DefaultEventsMap } from "socket.io"; import { Socket } from "socket.io-client"; import { faker } from "@faker-js/faker"; -import { EVENT_CHANGED } from "@core/constants/websocket.constants"; +import { + EVENT_CHANGED, + GOOGLE_REVOKED, +} from "@core/constants/websocket.constants"; import { Status } from "@core/errors/status.codes"; import { Resource_Sync, XGoogleResourceState } from "@core/types/sync.types"; import { Schema_User } from "@core/types/user.types"; @@ -23,12 +26,16 @@ import { cleanupTestDb, setupTestDb, } from "@backend/__tests__/helpers/mock.db.setup"; +import { invalidGrant400Error } from "@backend/__tests__/mocks.gcal/errors/error.google.invalidGrant"; import { WatchError } from "@backend/common/errors/sync/watch.errors"; import gcalService from "@backend/common/services/gcal/gcal.service"; import mongoService from "@backend/common/services/mongo.service"; +import { webSocketServer } from "@backend/servers/websocket/websocket.server"; +import syncService from "@backend/sync/services/sync.service"; import * as syncQueries from "@backend/sync/util/sync.queries"; import { updateSync } from "@backend/sync/util/sync.queries"; import userMetadataService from "@backend/user/services/user-metadata.service"; +import userService from "@backend/user/services/user.service"; describe("SyncController", () => { const baseDriver = new BaseDriver(); @@ -184,6 +191,54 @@ describe("SyncController", () => { expect(response.text).toEqual("IGNORED"); }); + + it("should prune Google data, notify client via websocket, and return structured response when user revokes access", async () => { + const { user } = await UtilDriver.setupTestUser(); + const userId = user._id.toString(); + + const watch = await mongoService.watch.findOne({ + user: userId, + gCalendarId: { $ne: Resource_Sync.CALENDAR }, + }); + + expect(watch).toBeDefined(); + expect(watch).not.toBeNull(); + + const handleGcalNotificationSpy = jest + .spyOn(syncService, "handleGcalNotification") + .mockRejectedValue(invalidGrant400Error); + + const pruneGoogleDataSpy = jest + .spyOn(userService, "pruneGoogleData") + .mockResolvedValue(); + + const handleGoogleRevokedSpy = jest.spyOn( + webSocketServer, + "handleGoogleRevoked", + ); + + const response = await syncDriver.handleGoogleNotification( + { + resource: Resource_Sync.EVENTS, + channelId: watch!._id, + resourceId: watch!.resourceId, + resourceState: XGoogleResourceState.EXISTS, + expiration: watch!.expiration, + }, + Status.GONE, + ); + + expect(response.body).toEqual({ + code: GOOGLE_REVOKED, + message: "User revoked access, pruned Google data", + }); + expect(pruneGoogleDataSpy).toHaveBeenCalledWith(userId); + expect(handleGoogleRevokedSpy).toHaveBeenCalledWith(userId); + + handleGcalNotificationSpy.mockRestore(); + pruneGoogleDataSpy.mockRestore(); + handleGoogleRevokedSpy.mockRestore(); + }); }); describe("importGCal: ", () => { diff --git a/packages/backend/src/sync/controllers/sync.controller.ts b/packages/backend/src/sync/controllers/sync.controller.ts index 22022e833..373b9349a 100644 --- a/packages/backend/src/sync/controllers/sync.controller.ts +++ b/packages/backend/src/sync/controllers/sync.controller.ts @@ -2,6 +2,7 @@ import { NextFunction, Request, Response } from "express"; import { ObjectId } from "mongodb"; import { ZodError } from "zod/v4"; import { COMPASS_RESOURCE_HEADER } from "@core/constants/core.constants"; +import { GOOGLE_REVOKED } from "@core/constants/websocket.constants"; import { Status } from "@core/errors/status.codes"; import { Logger } from "@core/logger/winston.logger"; import { @@ -18,6 +19,7 @@ import { isInvalidGoogleToken, } from "@backend/common/services/gcal/gcal.utils"; import mongoService from "@backend/common/services/mongo.service"; +import { webSocketServer } from "@backend/servers/websocket/websocket.server"; import syncService from "@backend/sync/services/sync.service"; import { getSync } from "@backend/sync/util/sync.queries"; import userService from "@backend/user/services/user.service"; @@ -87,11 +89,13 @@ export class SyncController { `Cleaning data after this user revoked access: ${userId}`, ); - await userService.deleteCompassDataForUser(userId, false); + await userService.pruneGoogleData(userId); + webSocketServer.handleGoogleRevoked(userId); - res - .status(Status.GONE) - .send("User revoked access, deleted all data"); + res.status(Status.GONE).send({ + code: GOOGLE_REVOKED, + message: "User revoked access, pruned Google data", + }); return; } diff --git a/packages/core/src/constants/websocket.constants.ts b/packages/core/src/constants/websocket.constants.ts index 1c21f9859..ee22e33f0 100644 --- a/packages/core/src/constants/websocket.constants.ts +++ b/packages/core/src/constants/websocket.constants.ts @@ -11,6 +11,8 @@ export const USER_METADATA = "USER_METADATA"; export const IMPORT_GCAL_START = "IMPORT_GCAL_START"; export const IMPORT_GCAL_END = "IMPORT_GCAL_END"; +export const GOOGLE_REVOKED = "GOOGLE_REVOKED"; + // client to server events export const EVENT_CHANGE_PROCESSED = "EVENT_CHANGE_PROCESSED"; export const SOMEDAY_EVENT_CHANGE_PROCESSED = "SOMEDAY_EVENT_CHANGE_PROCESSED"; diff --git a/packages/core/src/types/websocket.types.ts b/packages/core/src/types/websocket.types.ts index fa3fc8935..cb75908d2 100644 --- a/packages/core/src/types/websocket.types.ts +++ b/packages/core/src/types/websocket.types.ts @@ -33,6 +33,7 @@ export interface ServerToClientEvents { USER_METADATA: (data: UserMetadata) => void; IMPORT_GCAL_START: () => void; IMPORT_GCAL_END: (reason?: string) => void; + GOOGLE_REVOKED: () => void; } export interface SocketData { From caa4be8638be7f0c5e71b2cf0aee83306ba12874 Mon Sep 17 00:00:00 2001 From: Tyler Dane Date: Fri, 27 Feb 2026 19:06:53 -0800 Subject: [PATCH 14/23] feat(api): implement Google revoked access handling in CompassApi - Added handling for Google access revocation in CompassApi, ensuring users remain logged in while displaying a toast notification. - Introduced a new utility function to extract error codes from Axios errors, enhancing error management. - Updated tests to verify the new behavior, including scenarios for Google revoked access. - This enhancement improves user experience by providing clear feedback and managing Google Calendar data effectively. --- .../web/src/common/apis/compass.api.test.ts | 34 ++++++++- packages/web/src/common/apis/compass.api.ts | 39 +++++++++- .../src/common/apis/compass.api.util.test.ts | 75 +++++++++++++++++++ .../web/src/common/apis/compass.api.util.ts | 12 +++ .../src/common/constants/toast.constants.ts | 4 +- .../src/ducks/events/context/sync.context.ts | 1 + .../src/ducks/events/slices/event.slice.ts | 15 +++- .../web/src/socket/hooks/useGcalSync.test.ts | 49 ++++++++++++ packages/web/src/socket/hooks/useGcalSync.ts | 31 ++++++++ .../src/views/Calendar/hooks/useRefetch.ts | 17 +++++ 10 files changed, 269 insertions(+), 8 deletions(-) create mode 100644 packages/web/src/common/apis/compass.api.util.test.ts create mode 100644 packages/web/src/common/apis/compass.api.util.ts diff --git a/packages/web/src/common/apis/compass.api.test.ts b/packages/web/src/common/apis/compass.api.test.ts index ba442be04..e815f37e9 100644 --- a/packages/web/src/common/apis/compass.api.test.ts +++ b/packages/web/src/common/apis/compass.api.test.ts @@ -8,6 +8,7 @@ import { toast } from "react-toastify"; import { signOut } from "supertokens-web-js/recipe/session"; import { Status } from "@core/errors/status.codes"; import { ROOT_ROUTES } from "@web/common/constants/routes"; +import { GOOGLE_REVOKED_TOAST_ID } from "@web/common/constants/toast.constants"; import { CompassApi } from "./compass.api"; jest.mock("supertokens-web-js/recipe/session", () => { @@ -42,11 +43,15 @@ const setLocationPath = (pathname: string) => { }); }; -const createAxiosError = (status: number, url?: string): AxiosError => { +const createAxiosError = ( + status: number, + url?: string, + data?: unknown, +): AxiosError => { const config = { url } as InternalAxiosRequestConfig; const response = { config, - data: {}, + data: data ?? {}, headers: {}, status, statusText: "Error", @@ -62,8 +67,12 @@ const createAxiosError = (status: number, url?: string): AxiosError => { } as AxiosError; }; -const triggerErrorResponse = async (status: number, url?: string) => { - const axiosError = createAxiosError(status, url); +const triggerErrorResponse = async ( + status: number, + url?: string, + data?: unknown, +) => { + const axiosError = createAxiosError(status, url, data); const adapter: AxiosAdapter = () => Promise.reject(axiosError); CompassApi.defaults.adapter = adapter; @@ -157,4 +166,21 @@ describe("CompassApi interceptor auth handling", () => { expect(signOut).not.toHaveBeenCalled(); expect(assignMock).not.toHaveBeenCalled(); }); + + it("does not sign out on 401/410 when response has GOOGLE_REVOKED code", async () => { + await triggerErrorResponse(Status.UNAUTHORIZED, undefined, { + code: "GOOGLE_REVOKED", + message: "Google access revoked.", + }); + + expect(toast.error).toHaveBeenCalledWith( + "Google access revoked. Your Google data has been removed.", + expect.objectContaining({ + toastId: GOOGLE_REVOKED_TOAST_ID, + autoClose: false, + }), + ); + expect(signOut).not.toHaveBeenCalled(); + expect(assignMock).not.toHaveBeenCalled(); + }); }); diff --git a/packages/web/src/common/apis/compass.api.ts b/packages/web/src/common/apis/compass.api.ts index 0143dd1e4..a49031d60 100644 --- a/packages/web/src/common/apis/compass.api.ts +++ b/packages/web/src/common/apis/compass.api.ts @@ -1,9 +1,18 @@ import axios, { AxiosError } from "axios"; +import { toast } from "react-toastify"; +import { Origin } from "@core/constants/core.constants"; +import { GOOGLE_REVOKED } from "@core/constants/websocket.constants"; import { Status } from "@core/errors/status.codes"; +import { getApiErrorCode } from "@web/common/apis/compass.api.util"; import { session } from "@web/common/classes/Session"; import { ENV_WEB } from "@web/common/constants/env.constants"; import { ROOT_ROUTES } from "@web/common/constants/routes"; +import { GOOGLE_REVOKED_TOAST_ID } from "@web/common/constants/toast.constants"; import { showSessionExpiredToast } from "@web/common/utils/toast/error-toast.util"; +import { Sync_AsyncStateContextReason } from "@web/ducks/events/context/sync.context"; +import { eventsEntitiesSlice } from "@web/ducks/events/slices/event.slice"; +import { triggerFetch } from "@web/ducks/events/slices/sync.slice"; +import { store } from "@web/store"; export const CompassApi = axios.create({ baseURL: ENV_WEB.API_BASEURL, @@ -11,7 +20,7 @@ export const CompassApi = axios.create({ type SignoutStatus = Status.UNAUTHORIZED | Status.NOT_FOUND | Status.GONE; -const _signOut = async (status: SignoutStatus) => { +const signOut = async (status: SignoutStatus) => { // since there are currently duplicate event fetches, // this prevents triggering a separate alert for each fetch // this can be removed once we have logic to cancel subsequent requests @@ -30,6 +39,23 @@ const _signOut = async (status: SignoutStatus) => { window.location.assign(ROOT_ROUTES.DAY); }; +const handleGoogleRevokedError = () => { + if (!toast.isActive(GOOGLE_REVOKED_TOAST_ID)) { + toast.error("Google access revoked. Your Google data has been removed.", { + toastId: GOOGLE_REVOKED_TOAST_ID, + autoClose: false, + }); + } + store.dispatch( + eventsEntitiesSlice.actions.removeEventsByOrigin({ + origins: [Origin.GOOGLE, Origin.GOOGLE_IMPORT], + }), + ); + store.dispatch( + triggerFetch({ reason: Sync_AsyncStateContextReason.GOOGLE_REVOKED }), + ); +}; + CompassApi.interceptors.response.use( (response) => { return response; @@ -51,12 +77,21 @@ CompassApi.interceptors.response.use( return Promise.reject(error); } + // Google revoked: keep user logged in, show toast, clear Google events, trigger refetch + if ( + (status === Status.GONE || status === Status.UNAUTHORIZED) && + getApiErrorCode(error) === GOOGLE_REVOKED + ) { + handleGoogleRevokedError(); + return Promise.reject(error); + } + if ( status === Status.GONE || status === Status.NOT_FOUND || status === Status.UNAUTHORIZED ) { - await _signOut(status); + await signOut(status); } else { console.error(error); } diff --git a/packages/web/src/common/apis/compass.api.util.test.ts b/packages/web/src/common/apis/compass.api.util.test.ts new file mode 100644 index 000000000..b939fbddd --- /dev/null +++ b/packages/web/src/common/apis/compass.api.util.test.ts @@ -0,0 +1,75 @@ +import type { + AxiosError, + AxiosResponse, + InternalAxiosRequestConfig, +} from "axios"; +import { getApiErrorCode } from "./compass.api.util"; + +const createAxiosError = (response: { data?: unknown } | null): AxiosError => + ({ + response: response + ? ({ + data: response.data, + config: {} as InternalAxiosRequestConfig, + headers: {}, + status: 400, + statusText: "Error", + } as AxiosResponse) + : undefined, + }) as AxiosError; + +describe("getApiErrorCode", () => { + it("returns the code when response.data has a string code property", () => { + const error = createAxiosError({ data: { code: "GOOGLE_REVOKED" } }); + expect(getApiErrorCode(error)).toBe("GOOGLE_REVOKED"); + }); + + it("returns the code for arbitrary error codes", () => { + const error = createAxiosError({ data: { code: "FULL_SYNC_REQUIRED" } }); + expect(getApiErrorCode(error)).toBe("FULL_SYNC_REQUIRED"); + }); + + it("returns undefined when error has no response", () => { + const error = createAxiosError(null); + expect(getApiErrorCode(error)).toBeUndefined(); + }); + + it("returns undefined when response has no data", () => { + const error = createAxiosError({ data: undefined }); + expect(getApiErrorCode(error)).toBeUndefined(); + }); + + it("returns undefined when data is not an object", () => { + const error = createAxiosError({ data: "string body" }); + expect(getApiErrorCode(error)).toBeUndefined(); + }); + + it("returns undefined when data is an array", () => { + const error = createAxiosError({ data: [] }); + expect(getApiErrorCode(error)).toBeUndefined(); + }); + + it("returns undefined when data has no code property", () => { + const error = createAxiosError({ + data: { message: "Something went wrong" }, + }); + expect(getApiErrorCode(error)).toBeUndefined(); + }); + + it("returns undefined when code is not a string", () => { + const error = createAxiosError({ data: { code: 404 } }); + expect(getApiErrorCode(error)).toBeUndefined(); + }); + + it("returns undefined when code is null", () => { + const error = createAxiosError({ data: { code: null } }); + expect(getApiErrorCode(error)).toBeUndefined(); + }); + + it("preserves message when data has both code and message", () => { + const error = createAxiosError({ + data: { code: "GOOGLE_REVOKED", message: "Google access revoked." }, + }); + expect(getApiErrorCode(error)).toBe("GOOGLE_REVOKED"); + }); +}); diff --git a/packages/web/src/common/apis/compass.api.util.ts b/packages/web/src/common/apis/compass.api.util.ts new file mode 100644 index 000000000..1b6187d18 --- /dev/null +++ b/packages/web/src/common/apis/compass.api.util.ts @@ -0,0 +1,12 @@ +import type { AxiosError } from "axios"; + +/** + * Extracts the error code from an Axios error's response data. + * Returns undefined when the response has no object body with a string `code` property. + */ +export const getApiErrorCode = (error: AxiosError): string | undefined => { + const data = error?.response?.data; + if (!data || typeof data !== "object" || !("code" in data)) return undefined; + const code = (data as { code?: unknown }).code; + return typeof code === "string" ? code : undefined; +}; diff --git a/packages/web/src/common/constants/toast.constants.ts b/packages/web/src/common/constants/toast.constants.ts index 2f9d695bd..cb9b19ab1 100644 --- a/packages/web/src/common/constants/toast.constants.ts +++ b/packages/web/src/common/constants/toast.constants.ts @@ -1,7 +1,9 @@ -import { ToastOptions } from "react-toastify"; +import { Id, ToastOptions } from "react-toastify"; import { c } from "@web/common/styles/colors"; import { theme } from "@web/common/styles/theme"; +export const GOOGLE_REVOKED_TOAST_ID: Id = "google-revoked-api"; + export const toastDefaultOptions: ToastOptions = { autoClose: 5000, position: "bottom-left", diff --git a/packages/web/src/ducks/events/context/sync.context.ts b/packages/web/src/ducks/events/context/sync.context.ts index 3dd327d76..c8200b8de 100644 --- a/packages/web/src/ducks/events/context/sync.context.ts +++ b/packages/web/src/ducks/events/context/sync.context.ts @@ -2,4 +2,5 @@ export enum Sync_AsyncStateContextReason { SOCKET_EVENT_CHANGED = "SOCKET_EVENT_CHANGED", SOCKET_SOMEDAY_EVENT_CHANGED = "SOCKET_SOMEDAY_EVENT_CHANGED", IMPORT_COMPLETE = "IMPORT_COMPLETE", + GOOGLE_REVOKED = "GOOGLE_REVOKED", } diff --git a/packages/web/src/ducks/events/slices/event.slice.ts b/packages/web/src/ducks/events/slices/event.slice.ts index b91221e00..337c9677b 100644 --- a/packages/web/src/ducks/events/slices/event.slice.ts +++ b/packages/web/src/ducks/events/slices/event.slice.ts @@ -1,5 +1,6 @@ import produce from "immer"; -import { createSlice } from "@reduxjs/toolkit"; +import { PayloadAction, createSlice } from "@reduxjs/toolkit"; +import { Origin } from "@core/constants/core.constants"; import { Schema_Event } from "@core/types/event.types"; import dayjs from "@core/util/date/dayjs"; import { createAsyncSlice } from "@web/common/store/helpers"; @@ -65,6 +66,18 @@ export const eventsEntitiesSlice = createSlice({ const nextState = changeTimezones(state, action.payload.timezone); state.value = nextState.value; }, + removeEventsByOrigin: ( + state, + action: PayloadAction<{ origins: readonly Origin[] }>, + ) => { + const origins = new Set(action.payload.origins); + for (const id of Object.keys(state.value)) { + const event = state.value[id] as Schema_Event | undefined; + if (event?.origin && origins.has(event.origin as Origin)) { + delete state.value[id]; + } + } + }, }, }); diff --git a/packages/web/src/socket/hooks/useGcalSync.test.ts b/packages/web/src/socket/hooks/useGcalSync.test.ts index 6331a07fc..29e3ca1c0 100644 --- a/packages/web/src/socket/hooks/useGcalSync.test.ts +++ b/packages/web/src/socket/hooks/useGcalSync.test.ts @@ -1,6 +1,8 @@ import { useDispatch } from "react-redux"; import { renderHook } from "@testing-library/react"; +import { Origin } from "@core/constants/core.constants"; import { + GOOGLE_REVOKED, IMPORT_GCAL_END, IMPORT_GCAL_START, USER_METADATA, @@ -9,6 +11,7 @@ import { selectImporting, selectIsImportPending, } from "@web/ducks/events/selectors/sync.selector"; +import { eventsEntitiesSlice } from "@web/ducks/events/slices/event.slice"; import { importGCalSlice, triggerFetch, @@ -40,12 +43,22 @@ jest.mock("@web/ducks/events/slices/sync.slice", () => ({ request: jest.fn(), }, }, + importLatestSlice: { + reducer: (state = { isFetchNeeded: false, reason: null }) => state, + actions: { resetIsFetchNeeded: jest.fn() }, + }, triggerFetch: jest.fn(), })); // Mock shouldImportGCal util jest.mock("@core/util/event/event.util", () => ({ shouldImportGCal: jest.fn(() => false), })); +jest.mock("react-toastify", () => ({ + toast: { + error: jest.fn(), + isActive: jest.fn(() => false), + }, +})); describe("useGcalSync", () => { const mockDispatch = jest.fn(); @@ -83,6 +96,42 @@ describe("useGcalSync", () => { IMPORT_GCAL_END, expect.any(Function), ); + expect(socket.on).toHaveBeenCalledWith( + GOOGLE_REVOKED, + expect.any(Function), + ); + }); + + describe("GOOGLE_REVOKED", () => { + it("shows toast, clears Google events, and triggers refetch", () => { + const { toast } = require("react-toastify"); + let onGoogleRevoked: (() => void) | undefined; + (socket.on as jest.Mock).mockImplementation((event, handler) => { + if (event === GOOGLE_REVOKED) { + onGoogleRevoked = handler; + } + }); + + renderHook(() => useGcalSync()); + + onGoogleRevoked?.(); + + expect(toast.error).toHaveBeenCalledWith( + "Google access revoked. Your Google data has been removed.", + expect.objectContaining({ + toastId: "google-revoked-api", + autoClose: false, + }), + ); + expect(mockDispatch).toHaveBeenCalledWith( + eventsEntitiesSlice.actions.removeEventsByOrigin({ + origins: [Origin.GOOGLE, Origin.GOOGLE_IMPORT], + }), + ); + expect(triggerFetch).toHaveBeenCalledWith({ + reason: "GOOGLE_REVOKED", + }); + }); }); describe("IMPORT_GCAL_START", () => { diff --git a/packages/web/src/socket/hooks/useGcalSync.ts b/packages/web/src/socket/hooks/useGcalSync.ts index 1dcb91e2f..0eb4e120e 100644 --- a/packages/web/src/socket/hooks/useGcalSync.ts +++ b/packages/web/src/socket/hooks/useGcalSync.ts @@ -1,14 +1,19 @@ import { useCallback, useEffect, useRef } from "react"; import { useDispatch } from "react-redux"; +import { toast } from "react-toastify"; +import { Origin } from "@core/constants/core.constants"; import { + GOOGLE_REVOKED, IMPORT_GCAL_END, IMPORT_GCAL_START, USER_METADATA, } from "@core/constants/websocket.constants"; import { UserMetadata } from "@core/types/user.types"; import { shouldImportGCal } from "@core/util/event/event.util"; +import { GOOGLE_REVOKED_TOAST_ID } from "@web/common/constants/toast.constants"; import { Sync_AsyncStateContextReason } from "@web/ducks/events/context/sync.context"; import { selectIsImportPending } from "@web/ducks/events/selectors/sync.selector"; +import { eventsEntitiesSlice } from "@web/ducks/events/slices/event.slice"; import { importGCalSlice, triggerFetch, @@ -72,6 +77,25 @@ export const useGcalSync = () => { [dispatch], ); + const onGoogleRevoked = useCallback(() => { + if (!toast.isActive(GOOGLE_REVOKED_TOAST_ID)) { + toast.error("Google access revoked. Your Google data has been removed.", { + toastId: GOOGLE_REVOKED_TOAST_ID, + autoClose: false, + }); + } + dispatch( + eventsEntitiesSlice.actions.removeEventsByOrigin({ + origins: [Origin.GOOGLE, Origin.GOOGLE_IMPORT], + }), + ); + dispatch( + triggerFetch({ + reason: Sync_AsyncStateContextReason.GOOGLE_REVOKED, + }), + ); + }, [dispatch]); + const onMetadataFetch = useCallback( (metadata: UserMetadata) => { const importGcal = shouldImportGCal(metadata); @@ -114,4 +138,11 @@ export const useGcalSync = () => { socket.removeListener(IMPORT_GCAL_END, onImportEnd); }; }, [onImportEnd]); + + useEffect(() => { + socket.on(GOOGLE_REVOKED, onGoogleRevoked); + return () => { + socket.removeListener(GOOGLE_REVOKED, onGoogleRevoked); + }; + }, [onGoogleRevoked]); }; diff --git a/packages/web/src/views/Calendar/hooks/useRefetch.ts b/packages/web/src/views/Calendar/hooks/useRefetch.ts index b747da2d1..bb79bfa4b 100644 --- a/packages/web/src/views/Calendar/hooks/useRefetch.ts +++ b/packages/web/src/views/Calendar/hooks/useRefetch.ts @@ -74,6 +74,23 @@ export const useRefetch = () => { } else { dispatch(getWeekEventsSlice.actions.request(payload)); } + + // Full refresh on Google revoked: also refetch someday events + if (_reason === Sync_AsyncStateContextReason.GOOGLE_REVOKED) { + const dateStart = dayjs(dateRange.start); + const { startDate, endDate } = computeSomedayEventsRequestFilter( + dateStart, + dateStart.endOf("month"), + ); + + dispatch( + getSomedayEventsSlice.actions.request({ + startDate, + endDate, + __context: { reason: _reason }, + }), + ); + } } dispatch(resetIsFetchNeeded()); From 61e48032e7cc2d4988ef4766360873849e34c4b1 Mon Sep 17 00:00:00 2001 From: Tyler Dane Date: Fri, 27 Feb 2026 19:17:44 -0800 Subject: [PATCH 15/23] chore: fix conflicts in useGcalSync --- packages/web/src/socket/hooks/useGcalSync.ts | 3 --- 1 file changed, 3 deletions(-) diff --git a/packages/web/src/socket/hooks/useGcalSync.ts b/packages/web/src/socket/hooks/useGcalSync.ts index 65bc7eaad..0eb4e120e 100644 --- a/packages/web/src/socket/hooks/useGcalSync.ts +++ b/packages/web/src/socket/hooks/useGcalSync.ts @@ -13,10 +13,7 @@ import { shouldImportGCal } from "@core/util/event/event.util"; import { GOOGLE_REVOKED_TOAST_ID } from "@web/common/constants/toast.constants"; import { Sync_AsyncStateContextReason } from "@web/ducks/events/context/sync.context"; import { selectIsImportPending } from "@web/ducks/events/selectors/sync.selector"; -<<<<<<< fix/1478-import-bug import { eventsEntitiesSlice } from "@web/ducks/events/slices/event.slice"; -======= ->>>>>>> main import { importGCalSlice, triggerFetch, From 37c2e7aa6ac2310015e7ba22d958dc907682e2c1 Mon Sep 17 00:00:00 2001 From: Tyler Dane Date: Fri, 27 Feb 2026 19:19:35 -0800 Subject: [PATCH 16/23] refactor: revert try/catch in event.controller --- .../src/event/controllers/event.controller.ts | 90 +++++++++++-------- 1 file changed, 51 insertions(+), 39 deletions(-) diff --git a/packages/backend/src/event/controllers/event.controller.ts b/packages/backend/src/event/controllers/event.controller.ts index e561afca5..7d8dddf4b 100644 --- a/packages/backend/src/event/controllers/event.controller.ts +++ b/packages/backend/src/event/controllers/event.controller.ts @@ -38,45 +38,51 @@ class EventController { req: SReqBody, res: Res_Promise, ) => { - const { body } = req; - const user = req.session?.getUserId() as string; + try { + const { body } = req; + const user = req.session?.getUserId() as string; - // Handle both single object and array cases - const events = Array.isArray(body) ? body : [body]; + // Handle both single object and array cases + const events = Array.isArray(body) ? body : [body]; - res.promise( - this.processEvents( + await this.processEvents( events.map((e) => ({ payload: { ...e, user }, status: CompassEventStatus.CONFIRMED, applyTo: RecurringEventUpdateScope.THIS_EVENT, })) as CompassEvent[], - ).then(() => ({ statusCode: Status.NO_CONTENT })), - ); + ); + + res.status(Status.NO_CONTENT).send(); + } catch (e) { + logger.error(e); + + res.status(Status.BAD_REQUEST).send(); + } }; delete = async (req: SessionRequest, res: Res_Promise) => { - const { query } = req; - const user = req.session?.getUserId() as string; - const _id = req.params["id"] as string; - - res.promise( - eventService - .readById(user, _id) - .then((event) => { - const applyTo = - query["applyTo"] ?? RecurringEventUpdateScope.THIS_EVENT; - - return this.processEvents([ - { - payload: event as CompassThisEvent["payload"], - status: CompassEventStatus.CANCELLED, - applyTo: applyTo as RecurringEventUpdateScope.THIS_EVENT, - }, - ]); - }) - .then(() => ({ statusCode: Status.NO_CONTENT })), - ); + try { + const { query } = req; + const user = req.session?.getUserId() as string; + const _id = req.params["id"] as string; + const event = await eventService.readById(user, _id); + const applyTo = query["applyTo"] ?? RecurringEventUpdateScope.THIS_EVENT; + + await this.processEvents([ + { + payload: event as CompassThisEvent["payload"], + status: CompassEventStatus.CANCELLED, + applyTo: applyTo as RecurringEventUpdateScope.THIS_EVENT, + }, + ]); + + res.status(Status.NO_CONTENT).send(); + } catch (e) { + logger.error(e); + + res.status(Status.BAD_REQUEST).send(); + } }; deleteAllByUser = async (req: SessionRequest, res: Res_Promise) => { @@ -132,21 +138,27 @@ class EventController { }; update = async (req: SReqBody, res: Res_Promise) => { - const { body, query, params, session } = req; - const user = session?.getUserId() as string; - const _id = params["id"] as string; - const payload = { ...body, user, _id } as CompassThisEvent["payload"]; - const applyTo = query["applyTo"] as RecurringEventUpdateScope.THIS_EVENT; - - res.promise( - this.processEvents([ + try { + const { body, query, params, session } = req; + const user = session?.getUserId() as string; + const _id = params["id"] as string; + const payload = { ...body, user, _id } as CompassThisEvent["payload"]; + const applyTo = query["applyTo"] as RecurringEventUpdateScope.THIS_EVENT; + + await this.processEvents([ { payload, status: CompassEventStatus.CONFIRMED, applyTo: applyTo ?? RecurringEventUpdateScope.THIS_EVENT, }, - ]).then(() => ({ statusCode: Status.NO_CONTENT })), - ); + ]); + + res.status(Status.NO_CONTENT).send(); + } catch (e) { + logger.error(e); + + res.status(Status.BAD_REQUEST).send(); + } }; } From ca7c9103b2510aa14b32bf14e786cd8e124313d2 Mon Sep 17 00:00:00 2001 From: Tyler Dane Date: Fri, 27 Feb 2026 19:21:42 -0800 Subject: [PATCH 17/23] test(compass): enhance Google revoked access handling tests - Updated the test for handling Google access revocation to ensure proper behavior on 401/410 responses. - Introduced a new payload structure for the GOOGLE_REVOKED error code and verified that the correct toast notification is displayed. - Improved test coverage by iterating over multiple status codes to validate consistent error handling and user feedback. --- .../web/src/common/apis/compass.api.test.ts | 26 ++++++++++++------- 1 file changed, 16 insertions(+), 10 deletions(-) diff --git a/packages/web/src/common/apis/compass.api.test.ts b/packages/web/src/common/apis/compass.api.test.ts index e815f37e9..b6b0be142 100644 --- a/packages/web/src/common/apis/compass.api.test.ts +++ b/packages/web/src/common/apis/compass.api.test.ts @@ -168,19 +168,25 @@ describe("CompassApi interceptor auth handling", () => { }); it("does not sign out on 401/410 when response has GOOGLE_REVOKED code", async () => { - await triggerErrorResponse(Status.UNAUTHORIZED, undefined, { + const googleRevokedPayload = { code: "GOOGLE_REVOKED", message: "Google access revoked.", + }; + const toastExpectation = expect.objectContaining({ + toastId: GOOGLE_REVOKED_TOAST_ID, + autoClose: false, }); - expect(toast.error).toHaveBeenCalledWith( - "Google access revoked. Your Google data has been removed.", - expect.objectContaining({ - toastId: GOOGLE_REVOKED_TOAST_ID, - autoClose: false, - }), - ); - expect(signOut).not.toHaveBeenCalled(); - expect(assignMock).not.toHaveBeenCalled(); + for (const status of [Status.UNAUTHORIZED, Status.GONE]) { + jest.clearAllMocks(); + await triggerErrorResponse(status, undefined, googleRevokedPayload); + + expect(toast.error).toHaveBeenCalledWith( + "Google access revoked. Your Google data has been removed.", + toastExpectation, + ); + expect(signOut).not.toHaveBeenCalled(); + expect(assignMock).not.toHaveBeenCalled(); + } }); }); From 546a81b505224c2306ea2f400a4d4a2ce078be7c Mon Sep 17 00:00:00 2001 From: Tyler Dane Date: Fri, 27 Feb 2026 19:30:31 -0800 Subject: [PATCH 18/23] test(socket): enhance useGcalSync tests with timer management - Added beforeEach and afterEach hooks to manage fake timers in the useGcalSync test suite. - This change improves test reliability by ensuring proper timer handling during asynchronous operations. --- packages/web/src/socket/hooks/useGcalSync.test.ts | 7 +++++++ 1 file changed, 7 insertions(+) diff --git a/packages/web/src/socket/hooks/useGcalSync.test.ts b/packages/web/src/socket/hooks/useGcalSync.test.ts index 29e3ca1c0..1f4428ad5 100644 --- a/packages/web/src/socket/hooks/useGcalSync.test.ts +++ b/packages/web/src/socket/hooks/useGcalSync.test.ts @@ -242,6 +242,13 @@ describe("useGcalSync", () => { }); describe("import flow interaction", () => { + beforeEach(() => { + jest.useFakeTimers(); + }); + afterEach(() => { + jest.useRealTimers(); + }); + it("shows spinner on import start and hides it on successful import end", () => { // Capture socket handlers to simulate backend events const handlers: Record void> = {}; From 43f53ca2d77d088d8d109f415dfa17d9f90e93cf Mon Sep 17 00:00:00 2001 From: Tyler Dane Date: Fri, 27 Feb 2026 19:36:03 -0800 Subject: [PATCH 19/23] refactor(errors): improve Google error handling type safety - Updated error handling functions to accept `unknown` type instead of specific error types, enhancing type safety and flexibility. - Adjusted the `handleExpressError` and `isFullSyncRequired` functions to work with the new type definitions, ensuring consistent error management across the application. - This change improves code robustness and prepares the error handling system for a wider range of error scenarios. --- .../common/errors/handlers/error.express.handler.ts | 2 +- .../backend/src/common/services/gcal/gcal.utils.ts | 11 +++++++---- .../backend/src/sync/controllers/sync.controller.ts | 2 +- 3 files changed, 9 insertions(+), 6 deletions(-) diff --git a/packages/backend/src/common/errors/handlers/error.express.handler.ts b/packages/backend/src/common/errors/handlers/error.express.handler.ts index 8a1650a8a..e11b53244 100644 --- a/packages/backend/src/common/errors/handlers/error.express.handler.ts +++ b/packages/backend/src/common/errors/handlers/error.express.handler.ts @@ -93,7 +93,7 @@ export const handleExpressError = async ( } if (isGoogleError(e)) { - await handleGoogleError(req, res, userId, e); + await handleGoogleError(req, res, userId, e as GaxiosError); } else { const errInfo = assembleErrorInfo(e); res.status(e.status || Status.INTERNAL_SERVER).send(errInfo); diff --git a/packages/backend/src/common/services/gcal/gcal.utils.ts b/packages/backend/src/common/services/gcal/gcal.utils.ts index d570bd39f..8a227ccfb 100644 --- a/packages/backend/src/common/services/gcal/gcal.utils.ts +++ b/packages/backend/src/common/services/gcal/gcal.utils.ts @@ -42,7 +42,7 @@ export const getEmailFromUrl = (url: string) => { }; // occurs when token expired or revoked -export const isInvalidGoogleToken = (e: GaxiosError | Error) => { +export const isInvalidGoogleToken = (e: unknown) => { if (!isGoogleError(e)) return false; const err = e as GaxiosError; @@ -57,9 +57,12 @@ export const isGoogleError = (e: unknown) => { return e instanceof GaxiosError || (e as any)?.name === "GaxiosError"; }; -export const isFullSyncRequired = (e: GaxiosError | Error) => { - if (isGoogleError(e) && e.code) { - const codeStr = typeof e.code === "string" ? e.code : String(e.code); +export const isFullSyncRequired = (e: unknown) => { + if (!isGoogleError(e)) return false; + + const err = e as GaxiosError; + if (err.code) { + const codeStr = typeof err.code === "string" ? err.code : String(err.code); if (parseInt(codeStr) === 410) { return true; } diff --git a/packages/backend/src/sync/controllers/sync.controller.ts b/packages/backend/src/sync/controllers/sync.controller.ts index 373b9349a..820b9d1eb 100644 --- a/packages/backend/src/sync/controllers/sync.controller.ts +++ b/packages/backend/src/sync/controllers/sync.controller.ts @@ -107,7 +107,7 @@ export class SyncController { res.status(Status.GONE).send(msg); return; - } else if (isFullSyncRequired(e as Error) && userId) { + } else if (isFullSyncRequired(e) && userId) { // do not await this call userService .restartGoogleCalendarSync(userId, { force: true }) From 4437b757e50fa6445388cc3dcf67bede5c434516 Mon Sep 17 00:00:00 2001 From: Tyler Dane Date: Fri, 27 Feb 2026 19:59:57 -0800 Subject: [PATCH 20/23] feat(sync): implement deleteWatchesByUser and enhance Google error handling - Introduced the deleteWatchesByUser method in SyncService to remove watch records for a specific user, improving data management. - Added createGoogleError utility for generating mock Google errors, enhancing test coverage and error handling. - Updated gcal.utils to include getGoogleErrorStatus for better error status retrieval from Google errors. - Enhanced tests for SyncService to validate behavior when handling Google errors, ensuring robust error management. --- .../mocks.gcal/errors/error.google.factory.ts | 59 +++++++ .../common/services/gcal/gcal.util.test.ts | 15 ++ .../src/common/services/gcal/gcal.utils.ts | 23 ++- .../src/sync/services/sync.service.test.ts | 161 ++++++++++++++++++ .../backend/src/sync/services/sync.service.ts | 36 +++- .../src/user/services/user.service.test.ts | 6 + .../backend/src/user/services/user.service.ts | 14 +- 7 files changed, 299 insertions(+), 15 deletions(-) create mode 100644 packages/backend/src/__tests__/mocks.gcal/errors/error.google.factory.ts create mode 100644 packages/backend/src/sync/services/sync.service.test.ts diff --git a/packages/backend/src/__tests__/mocks.gcal/errors/error.google.factory.ts b/packages/backend/src/__tests__/mocks.gcal/errors/error.google.factory.ts new file mode 100644 index 000000000..6377e12ed --- /dev/null +++ b/packages/backend/src/__tests__/mocks.gcal/errors/error.google.factory.ts @@ -0,0 +1,59 @@ +import { GaxiosError } from "gaxios"; + +export const createGoogleError = ( + overrides: { + code?: string | number; + responseStatus?: number; + message?: string; + } = {}, +) => { + const url = new URL("https://www.googleapis.com/calendar/v3"); + const headers = new Headers(); + + const error = new GaxiosError( + overrides.message ?? "test google error", + { + headers, + url, + }, + overrides.responseStatus + ? { + config: { + headers, + url, + }, + status: overrides.responseStatus, + statusText: "ERROR", + headers, + data: {}, + ok: false, + redirected: false, + type: "error" as ResponseType, + url: url.toString(), + body: null, + bodyUsed: false, + clone: () => { + throw new Error("Not implemented"); + }, + arrayBuffer: async () => { + throw new Error("Not implemented"); + }, + blob: async () => { + throw new Error("Not implemented"); + }, + formData: async () => { + throw new Error("Not implemented"); + }, + json: async () => ({}), + text: async () => "", + bytes: async () => { + throw new Error("Not implemented"); + }, + } + : undefined, + ); + + error.code = overrides.code; + + return error; +}; diff --git a/packages/backend/src/common/services/gcal/gcal.util.test.ts b/packages/backend/src/common/services/gcal/gcal.util.test.ts index 23f29b154..b64997cc9 100644 --- a/packages/backend/src/common/services/gcal/gcal.util.test.ts +++ b/packages/backend/src/common/services/gcal/gcal.util.test.ts @@ -1,8 +1,10 @@ +import { createGoogleError } from "../../../__tests__/mocks.gcal/errors/error.google.factory"; import { invalidGrant400Error } from "../../../__tests__/mocks.gcal/errors/error.google.invalidGrant"; import { invalidValueError } from "../../../__tests__/mocks.gcal/errors/error.google.invalidValue"; import { invalidSyncTokenError } from "../../../__tests__/mocks.gcal/errors/error.invalidSyncToken"; import { getEmailFromUrl, + getGoogleErrorStatus, isFullSyncRequired, isInvalidGoogleToken, isInvalidValue, @@ -18,6 +20,19 @@ describe("Google Error Parsing", () => { it("recognizes expired refresh token", () => { expect(isInvalidGoogleToken(invalidGrant400Error)).toBe(true); }); + it("returns response status when present", () => { + expect( + getGoogleErrorStatus( + createGoogleError({ code: "500", responseStatus: 401 }), + ), + ).toBe(401); + }); + it("falls back to the parsed gaxios code", () => { + expect(getGoogleErrorStatus(createGoogleError({ code: "410" }))).toBe(410); + }); + it("returns undefined for non-google errors", () => { + expect(getGoogleErrorStatus(new Error("nope"))).toBeUndefined(); + }); }); describe("Gaxios response parsing", () => { diff --git a/packages/backend/src/common/services/gcal/gcal.utils.ts b/packages/backend/src/common/services/gcal/gcal.utils.ts index 8a227ccfb..121f00bc4 100644 --- a/packages/backend/src/common/services/gcal/gcal.utils.ts +++ b/packages/backend/src/common/services/gcal/gcal.utils.ts @@ -57,18 +57,25 @@ export const isGoogleError = (e: unknown) => { return e instanceof GaxiosError || (e as any)?.name === "GaxiosError"; }; -export const isFullSyncRequired = (e: unknown) => { - if (!isGoogleError(e)) return false; +export const getGoogleErrorStatus = (e: unknown) => { + if (!isGoogleError(e)) return undefined; const err = e as GaxiosError; - if (err.code) { - const codeStr = typeof err.code === "string" ? err.code : String(err.code); - if (parseInt(codeStr) === 410) { - return true; - } + const responseStatus = err.response?.status; + + if (typeof responseStatus === "number") return responseStatus; + if (typeof err.code === "number") return err.code; + + if (typeof err.code === "string") { + const code = Number.parseInt(err.code, 10); + if (!Number.isNaN(code)) return code; } - return false; + return undefined; +}; + +export const isFullSyncRequired = (e: unknown) => { + return getGoogleErrorStatus(e) === 410; }; export const isInvalidValue = (e: GaxiosError) => { diff --git a/packages/backend/src/sync/services/sync.service.test.ts b/packages/backend/src/sync/services/sync.service.test.ts new file mode 100644 index 000000000..cc8aca16e --- /dev/null +++ b/packages/backend/src/sync/services/sync.service.test.ts @@ -0,0 +1,161 @@ +import { ObjectId } from "mongodb"; +import { faker } from "@faker-js/faker"; +import { WatchSchema } from "@core/types/watch.types"; +import { UserDriver } from "@backend/__tests__/drivers/user.driver"; +import { + cleanupCollections, + cleanupTestDb, + setupTestDb, +} from "@backend/__tests__/helpers/mock.db.setup"; +import { createGoogleError } from "@backend/__tests__/mocks.gcal/errors/error.google.factory"; +import { invalidGrant400Error } from "@backend/__tests__/mocks.gcal/errors/error.google.invalidGrant"; +import gcalService from "@backend/common/services/gcal/gcal.service"; +import mongoService from "@backend/common/services/mongo.service"; +import syncService from "@backend/sync/services/sync.service"; + +const createWatch = async (user: string) => { + const watch = WatchSchema.parse({ + _id: new ObjectId(), + user, + resourceId: faker.string.uuid(), + expiration: new Date(Date.now() + 60_000), + gCalendarId: faker.string.uuid(), + createdAt: new Date(), + }); + + await mongoService.watch.insertOne(watch); + + return watch; +}; + +describe("SyncService", () => { + beforeEach(setupTestDb); + beforeEach(cleanupCollections); + afterEach(() => jest.restoreAllMocks()); + afterAll(cleanupTestDb); + + describe("deleteWatchesByUser", () => { + it("deletes only the target user's watch records and returns their identities", async () => { + const firstUser = await UserDriver.createUser(); + const secondUser = await UserDriver.createUser(); + const firstUserWatchA = await createWatch(firstUser._id.toString()); + const firstUserWatchB = await createWatch(firstUser._id.toString()); + const secondUserWatch = await createWatch(secondUser._id.toString()); + + const deleted = await syncService.deleteWatchesByUser( + firstUser._id.toString(), + ); + + expect(deleted).toEqual( + expect.arrayContaining([ + { + channelId: firstUserWatchA._id.toString(), + resourceId: firstUserWatchA.resourceId, + }, + { + channelId: firstUserWatchB._id.toString(), + resourceId: firstUserWatchB.resourceId, + }, + ]), + ); + expect(deleted).toHaveLength(2); + expect( + await mongoService.watch.countDocuments({ + user: firstUser._id.toString(), + }), + ).toBe(0); + expect( + await mongoService.watch.findOne({ _id: secondUserWatch._id }), + ).toEqual(expect.objectContaining({ user: secondUser._id.toString() })); + }); + }); + + describe("stopWatch", () => { + it("deletes the local watch record when Google returns 401", async () => { + const user = await UserDriver.createUser(); + const watch = await createWatch(user._id.toString()); + + jest + .spyOn(gcalService, "stopWatch") + .mockRejectedValue( + createGoogleError({ code: "401", responseStatus: 401 }), + ); + + await expect( + syncService.stopWatch( + user._id.toString(), + watch._id.toString(), + watch.resourceId, + ), + ).resolves.toBeUndefined(); + + expect(await mongoService.watch.findOne({ _id: watch._id })).toBeNull(); + }); + + it("deletes the local watch record when Google returns invalid_grant", async () => { + const user = await UserDriver.createUser(); + const watch = await createWatch(user._id.toString()); + + jest + .spyOn(gcalService, "stopWatch") + .mockRejectedValue(invalidGrant400Error); + + await expect( + syncService.stopWatch( + user._id.toString(), + watch._id.toString(), + watch.resourceId, + ), + ).resolves.toBeUndefined(); + + expect(await mongoService.watch.findOne({ _id: watch._id })).toBeNull(); + }); + + it("preserves the existing delete behavior when Google returns 404", async () => { + const user = await UserDriver.createUser(); + const watch = await createWatch(user._id.toString()); + + jest + .spyOn(gcalService, "stopWatch") + .mockRejectedValue( + createGoogleError({ code: "404", responseStatus: 404 }), + ); + + await expect( + syncService.stopWatch( + user._id.toString(), + watch._id.toString(), + watch.resourceId, + ), + ).resolves.toBeUndefined(); + + expect(await mongoService.watch.findOne({ _id: watch._id })).toBeNull(); + }); + + it("rethrows unexpected Google stop errors and keeps the local watch", async () => { + const user = await UserDriver.createUser(); + const watch = await createWatch(user._id.toString()); + + jest + .spyOn(gcalService, "stopWatch") + .mockRejectedValue( + createGoogleError({ code: "500", responseStatus: 500 }), + ); + + await expect( + syncService.stopWatch( + user._id.toString(), + watch._id.toString(), + watch.resourceId, + ), + ).rejects.toMatchObject({ code: "500" }); + + expect( + await mongoService.watch.findOne({ + _id: watch._id, + resourceId: watch.resourceId, + }), + ).toEqual(expect.objectContaining({ user: user._id.toString() })); + }); + }); +}); diff --git a/packages/backend/src/sync/services/sync.service.ts b/packages/backend/src/sync/services/sync.service.ts index a473d8946..041874536 100644 --- a/packages/backend/src/sync/services/sync.service.ts +++ b/packages/backend/src/sync/services/sync.service.ts @@ -1,4 +1,3 @@ -import { GaxiosError } from "gaxios"; import { ClientSession, ObjectId } from "mongodb"; import { RESULT_NOTIFIED_CLIENT } from "@core/constants/websocket.constants"; import { Logger } from "@core/logger/winston.logger"; @@ -19,6 +18,10 @@ import { error } from "@backend/common/errors/handlers/error.handler"; import { SyncError } from "@backend/common/errors/sync/sync.errors"; import { WatchError } from "@backend/common/errors/sync/watch.errors"; import gcalService from "@backend/common/services/gcal/gcal.service"; +import { + getGoogleErrorStatus, + isInvalidGoogleToken, +} from "@backend/common/services/gcal/gcal.utils"; import mongoService from "@backend/common/services/mongo.service"; import { webSocketServer } from "@backend/servers/websocket/websocket.server"; import { createSyncImport } from "@backend/sync/services/import/sync.import"; @@ -69,6 +72,22 @@ class SyncService { return response; }; + deleteWatchesByUser = async ( + user: string, + session?: ClientSession, + ): Promise => { + const watches = await mongoService.watch + .find({ user }, { session }) + .toArray(); + + await mongoService.watch.deleteMany({ user }, { session }); + + return watches.map(({ _id, resourceId }) => ({ + channelId: _id.toString(), + resourceId, + })); + }; + async cleanupStaleWatchChannel({ channelId, resourceId, @@ -626,10 +645,9 @@ class SyncService { return { channelId, resourceId }; } catch (e) { - const _e = e as GaxiosError; - const code = (_e.code as unknown as number) || 0; + const status = getGoogleErrorStatus(e); - if (_e.code === "404" || code === 404) { + if (status === 404) { await mongoService.watch.deleteOne(filter, { session }); logger.warn( @@ -639,6 +657,16 @@ class SyncService { return undefined; } + if (status === 401 || isInvalidGoogleToken(e)) { + await mongoService.watch.deleteOne(filter, { session }); + + logger.warn( + "Google authorization is no longer valid. Corresponding sync record deleted", + ); + + return undefined; + } + throw e; } }; diff --git a/packages/backend/src/user/services/user.service.test.ts b/packages/backend/src/user/services/user.service.test.ts index 09fc2a9fb..7cbb7c70b 100644 --- a/packages/backend/src/user/services/user.service.test.ts +++ b/packages/backend/src/user/services/user.service.test.ts @@ -15,6 +15,7 @@ import { UserError } from "@backend/common/errors/user/user.errors"; import { initSupertokens } from "@backend/common/middleware/supertokens.middleware"; import mongoService from "@backend/common/services/mongo.service"; import priorityService from "@backend/priority/services/priority.service"; +import syncService from "@backend/sync/services/sync.service"; import userMetadataService from "@backend/user/services/user-metadata.service"; import userService from "@backend/user/services/user.service"; @@ -203,6 +204,8 @@ describe("UserService", () => { it("stops sync and removes google field from user document", async () => { const user = await UserDriver.createUser(); const userId = user._id.toString(); + const stopWatchesSpy = jest.spyOn(syncService, "stopWatches"); + const deleteWatchesSpy = jest.spyOn(syncService, "deleteWatchesByUser"); expect(user.google).toBeDefined(); @@ -215,6 +218,9 @@ describe("UserService", () => { await userService.pruneGoogleData(userId); + expect(stopWatchesSpy).not.toHaveBeenCalled(); + expect(deleteWatchesSpy).toHaveBeenCalledWith(userId); + const storedUser = await mongoService.user.findOne({ _id: user._id }); expect(storedUser?.google).toBeUndefined(); diff --git a/packages/backend/src/user/services/user.service.ts b/packages/backend/src/user/services/user.service.ts index 9c6c7b6fa..4193ea7b6 100644 --- a/packages/backend/src/user/services/user.service.ts +++ b/packages/backend/src/user/services/user.service.ts @@ -158,17 +158,25 @@ class UserService { return cUser; }; - stopGoogleCalendarSync = async (user: string | ObjectId): Promise => { + stopGoogleCalendarSync = async ( + user: string | ObjectId, + options?: { skipGoogleWatchStop?: boolean }, + ): Promise => { const userId = zObjectId.parse(user).toString(); + const skipGoogleWatchStop = options?.skipGoogleWatchStop === true; await eventService.deleteByIntegration("google", userId); - await syncService.stopWatches(userId); + if (skipGoogleWatchStop) { + await syncService.deleteWatchesByUser(userId); + } else { + await syncService.stopWatches(userId); + } await syncService.deleteByIntegration("google", userId); }; pruneGoogleData = async (userId: string): Promise => { const _id = zObjectId.parse(userId); - await this.stopGoogleCalendarSync(userId); + await this.stopGoogleCalendarSync(userId, { skipGoogleWatchStop: true }); await mongoService.user.updateOne({ _id }, { $unset: { google: "" } }); }; From 657152f51e8f37837c8113cb350b5095f548c354 Mon Sep 17 00:00:00 2001 From: Tyler Dane Date: Sun, 1 Mar 2026 15:08:58 -0800 Subject: [PATCH 21/23] feat(auth): implement handleGoogleRevoked utility for improved Google access management - Introduced the handleGoogleRevoked function to centralize handling of Google access revocation, including toast notifications and event management. - Updated CompassApi and useGcalSync to utilize the new utility, ensuring consistent behavior across the application. - Enhanced tests for handleGoogleRevoked to verify correct toast display and event dispatching, improving test coverage and reliability. - This change enhances user experience by providing clear feedback and managing Google Calendar data effectively upon access revocation. --- .../errors/handlers/error.handler.test.ts | 8 ++- packages/web/src/common/apis/compass.api.ts | 27 +------ .../utils/auth/google-auth.util.test.ts | 70 ++++++++++++++++++- .../src/common/utils/auth/google-auth.util.ts | 25 +++++++ .../web/src/socket/hooks/useGcalSync.test.ts | 27 +++---- packages/web/src/socket/hooks/useGcalSync.ts | 24 +------ 6 files changed, 114 insertions(+), 67 deletions(-) diff --git a/packages/backend/src/common/errors/handlers/error.handler.test.ts b/packages/backend/src/common/errors/handlers/error.handler.test.ts index b3a8ac788..b7d859060 100644 --- a/packages/backend/src/common/errors/handlers/error.handler.test.ts +++ b/packages/backend/src/common/errors/handlers/error.handler.test.ts @@ -49,7 +49,11 @@ describe("error.handler", () => { it("returns 401 with GOOGLE_REVOKED payload when Google token is invalid", async () => { const userId = "507f1f77bcf86cd799439011"; jest.spyOn(userService, "pruneGoogleData").mockResolvedValue(); - jest.spyOn(webSocketServer, "handleGoogleRevoked"); + const handleGoogleRevokedSpy = jest.spyOn( + webSocketServer, + "handleGoogleRevoked", + ); + handleGoogleRevokedSpy.mockImplementation(() => undefined); jest.spyOn(errorHandler, "isOperational").mockReturnValue(true); const send = jest.fn(); @@ -70,6 +74,8 @@ describe("error.handler", () => { code: GOOGLE_REVOKED, message: "Google access revoked. Your Google data has been removed.", }); + expect(handleGoogleRevokedSpy).toHaveBeenCalledWith(userId); + handleGoogleRevokedSpy.mockRestore(); }); }); }); diff --git a/packages/web/src/common/apis/compass.api.ts b/packages/web/src/common/apis/compass.api.ts index a49031d60..f0552b4c2 100644 --- a/packages/web/src/common/apis/compass.api.ts +++ b/packages/web/src/common/apis/compass.api.ts @@ -1,18 +1,12 @@ import axios, { AxiosError } from "axios"; -import { toast } from "react-toastify"; -import { Origin } from "@core/constants/core.constants"; import { GOOGLE_REVOKED } from "@core/constants/websocket.constants"; import { Status } from "@core/errors/status.codes"; import { getApiErrorCode } from "@web/common/apis/compass.api.util"; import { session } from "@web/common/classes/Session"; import { ENV_WEB } from "@web/common/constants/env.constants"; import { ROOT_ROUTES } from "@web/common/constants/routes"; -import { GOOGLE_REVOKED_TOAST_ID } from "@web/common/constants/toast.constants"; import { showSessionExpiredToast } from "@web/common/utils/toast/error-toast.util"; -import { Sync_AsyncStateContextReason } from "@web/ducks/events/context/sync.context"; -import { eventsEntitiesSlice } from "@web/ducks/events/slices/event.slice"; -import { triggerFetch } from "@web/ducks/events/slices/sync.slice"; -import { store } from "@web/store"; +import { handleGoogleRevoked } from "../utils/auth/google-auth.util"; export const CompassApi = axios.create({ baseURL: ENV_WEB.API_BASEURL, @@ -39,23 +33,6 @@ const signOut = async (status: SignoutStatus) => { window.location.assign(ROOT_ROUTES.DAY); }; -const handleGoogleRevokedError = () => { - if (!toast.isActive(GOOGLE_REVOKED_TOAST_ID)) { - toast.error("Google access revoked. Your Google data has been removed.", { - toastId: GOOGLE_REVOKED_TOAST_ID, - autoClose: false, - }); - } - store.dispatch( - eventsEntitiesSlice.actions.removeEventsByOrigin({ - origins: [Origin.GOOGLE, Origin.GOOGLE_IMPORT], - }), - ); - store.dispatch( - triggerFetch({ reason: Sync_AsyncStateContextReason.GOOGLE_REVOKED }), - ); -}; - CompassApi.interceptors.response.use( (response) => { return response; @@ -82,7 +59,7 @@ CompassApi.interceptors.response.use( (status === Status.GONE || status === Status.UNAUTHORIZED) && getApiErrorCode(error) === GOOGLE_REVOKED ) { - handleGoogleRevokedError(); + handleGoogleRevoked(); return Promise.reject(error); } diff --git a/packages/web/src/common/utils/auth/google-auth.util.test.ts b/packages/web/src/common/utils/auth/google-auth.util.test.ts index fa0cc533a..28c57ab0f 100644 --- a/packages/web/src/common/utils/auth/google-auth.util.test.ts +++ b/packages/web/src/common/utils/auth/google-auth.util.test.ts @@ -1,10 +1,32 @@ +import { toast } from "react-toastify"; +import { Origin } from "@core/constants/core.constants"; import { AuthApi } from "@web/common/apis/auth.api"; +import { GOOGLE_REVOKED_TOAST_ID } from "@web/common/constants/toast.constants"; import { syncLocalEventsToCloud } from "@web/common/utils/sync/local-event-sync.util"; import { SignInUpInput } from "@web/components/oauth/ouath.types"; -import { authenticate, syncLocalEvents } from "./google-auth.util"; +import { Sync_AsyncStateContextReason } from "@web/ducks/events/context/sync.context"; +import { eventsEntitiesSlice } from "@web/ducks/events/slices/event.slice"; +import { triggerFetch } from "@web/ducks/events/slices/sync.slice"; +import { store } from "@web/store"; +import { + authenticate, + handleGoogleRevoked, + syncLocalEvents, +} from "./google-auth.util"; jest.mock("@web/common/apis/auth.api"); jest.mock("@web/common/utils/sync/local-event-sync.util"); +jest.mock("react-toastify", () => ({ + toast: { + error: jest.fn(), + isActive: jest.fn(() => false), + }, +})); +jest.mock("@web/store", () => ({ + store: { + dispatch: jest.fn(), + }, +})); const mockAuthApi = AuthApi as jest.Mocked; const mockSyncLocalEventsToCloud = @@ -89,4 +111,50 @@ describe("google-auth.util", () => { expect(result).toEqual({ syncedCount: 0, success: false, error }); }); }); + + describe("handleGoogleRevoked", () => { + beforeEach(() => { + jest.clearAllMocks(); + (toast.isActive as jest.Mock).mockReturnValue(false); + }); + + it("shows toast with GOOGLE_REVOKED_TOAST_ID when not already active", () => { + handleGoogleRevoked(); + + expect(toast.error).toHaveBeenCalledWith( + "Google access revoked. Your Google data has been removed.", + expect.objectContaining({ + toastId: GOOGLE_REVOKED_TOAST_ID, + autoClose: false, + }), + ); + }); + + it("dispatches removeEventsByOrigin for Google origins", () => { + handleGoogleRevoked(); + + expect(store.dispatch).toHaveBeenCalledWith( + eventsEntitiesSlice.actions.removeEventsByOrigin({ + origins: [Origin.GOOGLE, Origin.GOOGLE_IMPORT], + }), + ); + }); + + it("dispatches triggerFetch with GOOGLE_REVOKED reason", () => { + handleGoogleRevoked(); + + expect(store.dispatch).toHaveBeenCalledWith( + triggerFetch({ reason: Sync_AsyncStateContextReason.GOOGLE_REVOKED }), + ); + }); + + it("does not show toast when one is already active (idempotent)", () => { + (toast.isActive as jest.Mock).mockReturnValue(true); + + handleGoogleRevoked(); + + expect(toast.error).not.toHaveBeenCalled(); + expect(store.dispatch).toHaveBeenCalledTimes(2); + }); + }); }); diff --git a/packages/web/src/common/utils/auth/google-auth.util.ts b/packages/web/src/common/utils/auth/google-auth.util.ts index 4d72f7276..9d2b42f92 100644 --- a/packages/web/src/common/utils/auth/google-auth.util.ts +++ b/packages/web/src/common/utils/auth/google-auth.util.ts @@ -1,6 +1,13 @@ +import { toast } from "react-toastify"; +import { Origin } from "@core/constants/core.constants"; import { AuthApi } from "@web/common/apis/auth.api"; +import { GOOGLE_REVOKED_TOAST_ID } from "@web/common/constants/toast.constants"; import { syncLocalEventsToCloud } from "@web/common/utils/sync/local-event-sync.util"; import { SignInUpInput } from "@web/components/oauth/ouath.types"; +import { Sync_AsyncStateContextReason } from "@web/ducks/events/context/sync.context"; +import { eventsEntitiesSlice } from "@web/ducks/events/slices/event.slice"; +import { triggerFetch } from "@web/ducks/events/slices/sync.slice"; +import { store } from "@web/store"; export interface AuthenticateResult { success: boolean; @@ -27,6 +34,24 @@ export async function authenticate( } } +/** Idempotent handler for Google access revocation. Safe to call from both API interceptor and socket handler. */ +export const handleGoogleRevoked = () => { + if (!toast.isActive(GOOGLE_REVOKED_TOAST_ID)) { + toast.error("Google access revoked. Your Google data has been removed.", { + toastId: GOOGLE_REVOKED_TOAST_ID, + autoClose: false, + }); + } + store.dispatch( + eventsEntitiesSlice.actions.removeEventsByOrigin({ + origins: [Origin.GOOGLE, Origin.GOOGLE_IMPORT], + }), + ); + store.dispatch( + triggerFetch({ reason: Sync_AsyncStateContextReason.GOOGLE_REVOKED }), + ); +}; + /** * Sync local events to the cloud. */ diff --git a/packages/web/src/socket/hooks/useGcalSync.test.ts b/packages/web/src/socket/hooks/useGcalSync.test.ts index 1f4428ad5..f640dabf7 100644 --- a/packages/web/src/socket/hooks/useGcalSync.test.ts +++ b/packages/web/src/socket/hooks/useGcalSync.test.ts @@ -1,6 +1,5 @@ import { useDispatch } from "react-redux"; import { renderHook } from "@testing-library/react"; -import { Origin } from "@core/constants/core.constants"; import { GOOGLE_REVOKED, IMPORT_GCAL_END, @@ -11,7 +10,6 @@ import { selectImporting, selectIsImportPending, } from "@web/ducks/events/selectors/sync.selector"; -import { eventsEntitiesSlice } from "@web/ducks/events/slices/event.slice"; import { importGCalSlice, triggerFetch, @@ -59,6 +57,9 @@ jest.mock("react-toastify", () => ({ isActive: jest.fn(() => false), }, })); +jest.mock("@web/common/utils/auth/google-auth.util", () => ({ + handleGoogleRevoked: jest.fn(), +})); describe("useGcalSync", () => { const mockDispatch = jest.fn(); @@ -103,8 +104,10 @@ describe("useGcalSync", () => { }); describe("GOOGLE_REVOKED", () => { - it("shows toast, clears Google events, and triggers refetch", () => { - const { toast } = require("react-toastify"); + it("calls handleGoogleRevoked when socket event fires", () => { + const { + handleGoogleRevoked, + } = require("@web/common/utils/auth/google-auth.util"); let onGoogleRevoked: (() => void) | undefined; (socket.on as jest.Mock).mockImplementation((event, handler) => { if (event === GOOGLE_REVOKED) { @@ -116,21 +119,7 @@ describe("useGcalSync", () => { onGoogleRevoked?.(); - expect(toast.error).toHaveBeenCalledWith( - "Google access revoked. Your Google data has been removed.", - expect.objectContaining({ - toastId: "google-revoked-api", - autoClose: false, - }), - ); - expect(mockDispatch).toHaveBeenCalledWith( - eventsEntitiesSlice.actions.removeEventsByOrigin({ - origins: [Origin.GOOGLE, Origin.GOOGLE_IMPORT], - }), - ); - expect(triggerFetch).toHaveBeenCalledWith({ - reason: "GOOGLE_REVOKED", - }); + expect(handleGoogleRevoked).toHaveBeenCalledTimes(1); }); }); diff --git a/packages/web/src/socket/hooks/useGcalSync.ts b/packages/web/src/socket/hooks/useGcalSync.ts index 0eb4e120e..e1c46986d 100644 --- a/packages/web/src/socket/hooks/useGcalSync.ts +++ b/packages/web/src/socket/hooks/useGcalSync.ts @@ -1,7 +1,5 @@ import { useCallback, useEffect, useRef } from "react"; import { useDispatch } from "react-redux"; -import { toast } from "react-toastify"; -import { Origin } from "@core/constants/core.constants"; import { GOOGLE_REVOKED, IMPORT_GCAL_END, @@ -10,10 +8,9 @@ import { } from "@core/constants/websocket.constants"; import { UserMetadata } from "@core/types/user.types"; import { shouldImportGCal } from "@core/util/event/event.util"; -import { GOOGLE_REVOKED_TOAST_ID } from "@web/common/constants/toast.constants"; +import { handleGoogleRevoked } from "@web/common/utils/auth/google-auth.util"; import { Sync_AsyncStateContextReason } from "@web/ducks/events/context/sync.context"; import { selectIsImportPending } from "@web/ducks/events/selectors/sync.selector"; -import { eventsEntitiesSlice } from "@web/ducks/events/slices/event.slice"; import { importGCalSlice, triggerFetch, @@ -78,23 +75,8 @@ export const useGcalSync = () => { ); const onGoogleRevoked = useCallback(() => { - if (!toast.isActive(GOOGLE_REVOKED_TOAST_ID)) { - toast.error("Google access revoked. Your Google data has been removed.", { - toastId: GOOGLE_REVOKED_TOAST_ID, - autoClose: false, - }); - } - dispatch( - eventsEntitiesSlice.actions.removeEventsByOrigin({ - origins: [Origin.GOOGLE, Origin.GOOGLE_IMPORT], - }), - ); - dispatch( - triggerFetch({ - reason: Sync_AsyncStateContextReason.GOOGLE_REVOKED, - }), - ); - }, [dispatch]); + handleGoogleRevoked(); + }, []); const onMetadataFetch = useCallback( (metadata: UserMetadata) => { From 5d8cc1824423ffc087d2590f392e9967bc4ff64b Mon Sep 17 00:00:00 2001 From: Tyler Dane Date: Sun, 1 Mar 2026 15:20:37 -0800 Subject: [PATCH 22/23] fix(event-test-utils): update keyboard shortcut for saving event forms - Changed the keyboard shortcut for saving event forms from "Enter" to "Meta+Enter" to accommodate different operating systems (Cmd+Enter on Mac, Win+Enter on Windows/Linux). - This update ensures consistent behavior across platforms when saving event data. --- e2e/utils/event-test-utils.ts | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/e2e/utils/event-test-utils.ts b/e2e/utils/event-test-utils.ts index 467489180..662f76a6a 100644 --- a/e2e/utils/event-test-utils.ts +++ b/e2e/utils/event-test-utils.ts @@ -212,7 +212,8 @@ export const fillTitleAndSaveWithKeyboard = async ( const titleInput = getFormTitleInput(page); await expect(titleInput).toBeVisible({ timeout: FORM_TIMEOUT }); await titleInput.fill(title); - await page.keyboard.press("Enter"); + // EventForm saves on Meta+Enter (Cmd+Enter on Mac, Win+Enter on Windows/Linux) + await page.keyboard.press("Meta+Enter"); // Wait for form to close, confirming the save completed await titleInput.waitFor({ state: "hidden", timeout: FORM_TIMEOUT }); }; From b99212752249de0bcf274bacdd8a9de6d9308b9a Mon Sep 17 00:00:00 2001 From: Tyler Dane Date: Sun, 1 Mar 2026 15:45:22 -0800 Subject: [PATCH 23/23] refactor(event-form): unify keyboard shortcuts for form submission - Updated keyboard shortcuts for submitting event forms to use "mod+enter" for consistency across platforms (Cmd+Enter on Mac, Ctrl+Enter on Windows/Linux). - Adjusted related tests and utility functions to reflect the new shortcut, ensuring reliable behavior in form submissions. - This change enhances user experience by providing a consistent interaction model across different operating systems. --- e2e/utils/event-test-utils.ts | 5 +++-- packages/web/src/views/Forms/EventForm/EventForm.tsx | 4 ++-- .../SomedayEventForm/useSomedayFormShortcuts.test.tsx | 10 +++++----- .../Forms/SomedayEventForm/useSomedayFormShortcuts.ts | 2 +- 4 files changed, 11 insertions(+), 10 deletions(-) diff --git a/e2e/utils/event-test-utils.ts b/e2e/utils/event-test-utils.ts index 662f76a6a..714766a0c 100644 --- a/e2e/utils/event-test-utils.ts +++ b/e2e/utils/event-test-utils.ts @@ -212,8 +212,9 @@ export const fillTitleAndSaveWithKeyboard = async ( const titleInput = getFormTitleInput(page); await expect(titleInput).toBeVisible({ timeout: FORM_TIMEOUT }); await titleInput.fill(title); - // EventForm saves on Meta+Enter (Cmd+Enter on Mac, Win+Enter on Windows/Linux) - await page.keyboard.press("Meta+Enter"); + // EventForm saves on Cmd+Enter (Mac) or Ctrl+Enter (Linux/Windows) + // ControlOrMeta maps to the platform-appropriate modifier + await page.keyboard.press("ControlOrMeta+Enter"); // Wait for form to close, confirming the save completed await titleInput.waitFor({ state: "hidden", timeout: FORM_TIMEOUT }); }; diff --git a/packages/web/src/views/Forms/EventForm/EventForm.tsx b/packages/web/src/views/Forms/EventForm/EventForm.tsx index e80a8b7ac..d5119602e 100644 --- a/packages/web/src/views/Forms/EventForm/EventForm.tsx +++ b/packages/web/src/views/Forms/EventForm/EventForm.tsx @@ -165,7 +165,7 @@ export const EventForm: React.FC> = memo( e.preventDefault(); } - if (e.metaKey && e.key === Key.Enter) { + if ((e.metaKey || e.ctrlKey) && e.key === Key.Enter) { e.preventDefault(); onSubmitForm(); } @@ -266,7 +266,7 @@ export const EventForm: React.FC> = memo( ); useHotkeys( - "meta+enter", + "mod+enter", (e) => { e.preventDefault(); onSubmitForm(); diff --git a/packages/web/src/views/Forms/SomedayEventForm/useSomedayFormShortcuts.test.tsx b/packages/web/src/views/Forms/SomedayEventForm/useSomedayFormShortcuts.test.tsx index 99f26b3f5..2c37fa15e 100644 --- a/packages/web/src/views/Forms/SomedayEventForm/useSomedayFormShortcuts.test.tsx +++ b/packages/web/src/views/Forms/SomedayEventForm/useSomedayFormShortcuts.test.tsx @@ -56,7 +56,7 @@ describe("SomedayEventForm shortcuts hook", () => { expect(registeredCombos).toEqual([ "delete", "enter", - "meta+enter", + "mod+enter", "meta+d", "ctrl+meta+up", "ctrl+meta+down", @@ -105,7 +105,7 @@ describe("SomedayEventForm shortcuts hook", () => { expect(defaultProps.onDuplicate).toHaveBeenCalledTimes(1); }); - test("meta+enter shortcut triggers submit with propagation blocked", () => { + test("mod+enter shortcut triggers submit with propagation blocked", () => { render(); const keyboardEvent = { @@ -114,13 +114,13 @@ describe("SomedayEventForm shortcuts hook", () => { target: document.createElement("input"), } as unknown as KeyboardEvent; - const handler = getHotkeyHandler("meta+enter"); + const handler = getHotkeyHandler("mod+enter"); handler(keyboardEvent); expect(defaultProps.onSubmit).toHaveBeenCalledTimes(1); }); - test("meta+enter shortcut invokes onSubmit", () => { + test("mod+enter shortcut invokes onSubmit", () => { render(); const menuButton = document.createElement("button"); @@ -132,7 +132,7 @@ describe("SomedayEventForm shortcuts hook", () => { target: menuButton, } as unknown as KeyboardEvent; - const handler = getHotkeyHandler("meta+enter"); + const handler = getHotkeyHandler("mod+enter"); handler(keyboardEvent); expect(defaultProps.onSubmit).toHaveBeenCalled(); diff --git a/packages/web/src/views/Forms/SomedayEventForm/useSomedayFormShortcuts.ts b/packages/web/src/views/Forms/SomedayEventForm/useSomedayFormShortcuts.ts index a01034511..53c76b9aa 100644 --- a/packages/web/src/views/Forms/SomedayEventForm/useSomedayFormShortcuts.ts +++ b/packages/web/src/views/Forms/SomedayEventForm/useSomedayFormShortcuts.ts @@ -96,7 +96,7 @@ export const useSomedayFormShortcuts = ({ [onSubmit], ); useHotkeys( - "meta+enter", + "mod+enter", (keyboardEvent) => { keyboardEvent.preventDefault(); keyboardEvent.stopPropagation();