From a03f9e47753b24044c928f21397e1346e830c194 Mon Sep 17 00:00:00 2001 From: Tyler Dane Date: Sat, 7 Mar 2026 01:36:36 -0800 Subject: [PATCH 01/24] feat(user-metadata): enhance Google metadata handling and update tests - Updated the user metadata service to assess Google connection and sync status, enriching metadata with connection status and sync state. - Introduced a new driver for user metadata service tests to streamline testing of metadata updates. - Modified existing tests to validate new Google metadata assessment logic, ensuring accurate handling of connection and sync states. - Refactored user driver to support conditional user creation based on Google connection status. - Cleaned up WebSocket server logic by removing deprecated Google token status checks on connection. --- package.json | 2 +- .../drivers/user-metadata.service.driver.ts | 19 ++ .../src/__tests__/drivers/user.driver.ts | 16 +- .../websocket/websocket.server.test.ts | 125 +---------- .../src/servers/websocket/websocket.server.ts | 26 --- .../src/sync/controllers/sync.controller.ts | 19 +- .../backend/src/sync/services/sync.service.ts | 8 + .../services/user-metadata.service.test.ts | 111 ++++++++-- .../user/services/user-metadata.service.ts | 202 ++++++++++++++++-- .../backend/src/user/services/user.service.ts | 2 +- packages/core/src/types/user.types.ts | 7 + .../src/auth/google/google.auth.util.test.ts | 22 +- .../web/src/auth/google/google.auth.util.ts | 11 + .../auth/hooks/oauth/useConnectGoogle.test.ts | 75 ++++--- .../src/auth/hooks/oauth/useConnectGoogle.ts | 55 ++++- .../web/src/auth/session/SessionProvider.tsx | 3 + .../auth/selectors/user-metadata.selectors.ts | 7 + .../ducks/auth/slices/user-metadata.slice.ts | 23 ++ .../web/src/socket/hooks/useGcalSync.test.ts | 43 +++- packages/web/src/socket/hooks/useGcalSync.ts | 8 +- packages/web/src/store/reducers.ts | 2 + .../web/src/views/CmdPalette/CmdPalette.tsx | 16 +- .../Day/components/DayCmdPalette.test.tsx | 97 ++++++--- .../views/Day/components/DayCmdPalette.tsx | 16 +- .../Now/components/NowCmdPalette.test.tsx | 97 ++++++--- .../views/Now/components/NowCmdPalette.tsx | 16 +- 26 files changed, 692 insertions(+), 336 deletions(-) create mode 100644 packages/backend/src/__tests__/drivers/user-metadata.service.driver.ts create mode 100644 packages/web/src/ducks/auth/selectors/user-metadata.selectors.ts create mode 100644 packages/web/src/ducks/auth/slices/user-metadata.slice.ts diff --git a/package.json b/package.json index 8fd15848d..19bc39030 100644 --- a/package.json +++ b/package.json @@ -29,7 +29,7 @@ "postinstall": "husky install | chmod ug+x .husky/*", "test": "cross-env TZ=Etc/UTC ./node_modules/.bin/jest", "test:e2e": "playwright test", - "test:backend": "yarn test backend", + "test:backend": "cross-env TZ=Etc/UTC ./node_modules/.bin/jest --selectProjects backend", "test:core": "yarn test core", "test:web": "cross-env TZ=Etc/UTC ./node_modules/.bin/jest web", "test:scripts": "yarn test scripts", diff --git a/packages/backend/src/__tests__/drivers/user-metadata.service.driver.ts b/packages/backend/src/__tests__/drivers/user-metadata.service.driver.ts new file mode 100644 index 000000000..cc4756304 --- /dev/null +++ b/packages/backend/src/__tests__/drivers/user-metadata.service.driver.ts @@ -0,0 +1,19 @@ +import type { JSONObject } from "supertokens-node/recipe/usermetadata"; +import { type UserMetadata } from "@core/types/user.types"; +import userMetadataService from "@backend/user/services/user-metadata.service"; + +export class UserMetadataServiceDriver { + updateUserMetadata(params: { + userId: string; + data: Partial; + }): Promise { + return userMetadataService.updateUserMetadata(params); + } + + fetchUserMetadata( + userId: string, + userContext?: Record, + ): Promise { + return userMetadataService.fetchUserMetadata(userId, userContext); + } +} diff --git a/packages/backend/src/__tests__/drivers/user.driver.ts b/packages/backend/src/__tests__/drivers/user.driver.ts index 85b046fa0..476724a00 100644 --- a/packages/backend/src/__tests__/drivers/user.driver.ts +++ b/packages/backend/src/__tests__/drivers/user.driver.ts @@ -7,6 +7,8 @@ import userService from "../../user/services/user.service"; interface CreateUserOptions { withGoogleRefreshToken?: boolean; + /** When false, creates a user with no Google data (never connected). */ + withGoogle?: boolean; } export class UserDriver { @@ -38,7 +40,7 @@ export class UserDriver { static async createUser( options: CreateUserOptions = {}, ): Promise> { - const { withGoogleRefreshToken = true } = options; + const { withGoogleRefreshToken = true, withGoogle = true } = options; const gUser = UserDriver.generateGoogleUser(); const gRefreshToken = faker.internet.jwt(); @@ -49,6 +51,14 @@ export class UserDriver { const _id = new ObjectId(userId); + // Simulate "user never connected Google" by removing all Google data + if (!withGoogle) { + await mongoService.user.updateOne({ _id }, { $unset: { google: "" } }); + // eslint-disable-next-line @typescript-eslint/no-unused-vars -- intentionally omit google from returned user + const { google: _google, ...rest } = user; + return { ...rest, _id }; + } + // Remove refresh token if requested (simulates revoked token scenario) if (!withGoogleRefreshToken) { await mongoService.user.updateOne( @@ -67,6 +77,8 @@ export class UserDriver { } static async createUsers(count: number): Promise>> { - return Promise.all(Array.from({ length: count }, UserDriver.createUser)); + return Promise.all( + Array.from({ length: count }, () => UserDriver.createUser()), + ); } } diff --git a/packages/backend/src/servers/websocket/websocket.server.test.ts b/packages/backend/src/servers/websocket/websocket.server.test.ts index 3386471a5..2b060a43d 100644 --- a/packages/backend/src/servers/websocket/websocket.server.test.ts +++ b/packages/backend/src/servers/websocket/websocket.server.test.ts @@ -10,13 +10,11 @@ import { EVENT_CHANGED, EVENT_CHANGE_PROCESSED, FETCH_USER_METADATA, - GOOGLE_REVOKED, SOMEDAY_EVENT_CHANGED, SOMEDAY_EVENT_CHANGE_PROCESSED, USER_METADATA, } from "@core/constants/websocket.constants"; import { type UserMetadata } from "@core/types/user.types"; -import { type Schema_User } from "@core/types/user.types"; import { BaseDriver } from "@backend/__tests__/drivers/base.driver"; import { webSocketServer } from "@backend/servers/websocket/websocket.server"; import { findCompassUserBy } from "@backend/user/queries/user.queries"; @@ -119,120 +117,6 @@ describe("WebSocket Server", () => { ); }); }); - - describe("checkGoogleTokenStatus on connection", () => { - it("emits GOOGLE_REVOKED to client when user has googleId but no gRefreshToken", async () => { - const userId = new ObjectId().toString(); - const userWithRevokedGoogle: Schema_User & { _id: ObjectId } = { - _id: new ObjectId(userId), - email: "user@example.com", - firstName: "First", - lastName: "Last", - name: "First Last", - locale: "en", - google: { - googleId: "google-123", - picture: "https://example.com/pic.png", - gRefreshToken: "", - }, - }; - mockFindCompassUserBy.mockResolvedValue(userWithRevokedGoogle); - - const client = baseDriver.createWebsocketClient( - { userId }, - { autoConnect: false }, - ); - - await expect( - baseDriver.waitUntilWebsocketEvent(client, GOOGLE_REVOKED, async () => - client.connect(), - ), - ).resolves.toEqual([]); - - expect(mockFindCompassUserBy).toHaveBeenCalledWith("_id", userId); - }); - - it("does not emit GOOGLE_REVOKED when user has gRefreshToken", async () => { - const userId = new ObjectId().toString(); - const userWithValidGoogle: Schema_User & { _id: ObjectId } = { - _id: new ObjectId(userId), - email: "user@example.com", - firstName: "First", - lastName: "Last", - name: "First Last", - locale: "en", - google: { - googleId: "google-123", - picture: "https://example.com/pic.png", - gRefreshToken: "valid-refresh-token", - }, - }; - mockFindCompassUserBy.mockResolvedValue(userWithValidGoogle); - - const client = baseDriver.createWebsocketClient( - { userId }, - { autoConnect: false }, - ); - let receivedGoogleRevoked = false; - client.on(GOOGLE_REVOKED, () => { - receivedGoogleRevoked = true; - }); - - await baseDriver.waitUntilWebsocketEvent(client, "connect", async () => - client.connect(), - ); - - await new Promise((r) => setTimeout(r, 400)); - - expect(receivedGoogleRevoked).toBe(false); - expect(mockFindCompassUserBy).toHaveBeenCalledWith("_id", userId); - }); - - it("does not emit GOOGLE_REVOKED when user is null", async () => { - const userId = new ObjectId().toString(); - mockFindCompassUserBy.mockResolvedValue(null); - - const client = baseDriver.createWebsocketClient( - { userId }, - { autoConnect: false }, - ); - let receivedGoogleRevoked = false; - client.on(GOOGLE_REVOKED, () => { - receivedGoogleRevoked = true; - }); - - await baseDriver.waitUntilWebsocketEvent(client, "connect", async () => - client.connect(), - ); - - await new Promise((r) => setTimeout(r, 400)); - - expect(receivedGoogleRevoked).toBe(false); - expect(mockFindCompassUserBy).toHaveBeenCalledWith("_id", userId); - }); - - it("does not emit GOOGLE_REVOKED when findCompassUserBy throws", async () => { - const userId = new ObjectId().toString(); - mockFindCompassUserBy.mockRejectedValue(new Error("DB error")); - - const client = baseDriver.createWebsocketClient( - { userId }, - { autoConnect: false }, - ); - let receivedGoogleRevoked = false; - client.on(GOOGLE_REVOKED, () => { - receivedGoogleRevoked = true; - }); - - await baseDriver.waitUntilWebsocketEvent(client, "connect", async () => - client.connect(), - ); - - await new Promise((r) => setTimeout(r, 400)); - - expect(receivedGoogleRevoked).toBe(false); - }); - }); }); describe("Emission: ", () => { @@ -510,7 +394,14 @@ describe("WebSocket Server", () => { await expect( baseDriver.waitUntilWebsocketEvent(client, USER_METADATA), ).resolves.toEqual([ - { ...userMetadata, google: { hasRefreshToken: false } }, + { + ...userMetadata, + google: { + hasRefreshToken: false, + connectionStatus: "not_connected", + syncStatus: "none", + }, + }, ]); }); }); diff --git a/packages/backend/src/servers/websocket/websocket.server.ts b/packages/backend/src/servers/websocket/websocket.server.ts index 811b32178..32b7237b0 100644 --- a/packages/backend/src/servers/websocket/websocket.server.ts +++ b/packages/backend/src/servers/websocket/websocket.server.ts @@ -30,7 +30,6 @@ import { ENV } from "@backend/common/constants/env.constants"; import { error } from "@backend/common/errors/handlers/error.handler"; import { SocketError } from "@backend/common/errors/socket/socket.errors"; import { handleWsError } from "@backend/servers/websocket/websocket.util"; -import { findCompassUserBy } from "@backend/user/queries/user.queries"; import userMetadataService from "@backend/user/services/user-metadata.service"; const logger = Logger("app:websocket.server"); @@ -84,31 +83,6 @@ class WebSocketServer { .then((data) => this.handleUserMetadata(sessionId, data)), ), ); - - // Proactively check Google token status on connection (fire-and-forget) - void this.checkGoogleTokenStatus(socket, userId); - } - - private async checkGoogleTokenStatus( - socket: CompassSocket, - userId: string, - ): Promise { - try { - const user = await findCompassUserBy("_id", userId); - - // User had Google connected (has googleId) but token is now missing - if (user && !user.google?.gRefreshToken && user.google?.googleId) { - logger.info( - `GOOGLE_REVOKED on connection - user has no refresh token: ${userId}`, - ); - this.notifyClient(socket.id, GOOGLE_REVOKED); - } - } catch (err) { - logger.error( - `Failed to check Google token status for user: ${userId}`, - err, - ); - } } private onDisconnect({ diff --git a/packages/backend/src/sync/controllers/sync.controller.ts b/packages/backend/src/sync/controllers/sync.controller.ts index c4c45dfc8..4dd7636a8 100644 --- a/packages/backend/src/sync/controllers/sync.controller.ts +++ b/packages/backend/src/sync/controllers/sync.controller.ts @@ -113,24 +113,7 @@ export class SyncController { resourceId, }); - const metadata = await userMetadataService.fetchUserMetadata(userId); - - if (metadata.sync?.importGCal === "importing") { - logger.info( - `Skipped Google sync recovery because full import is already running for user: ${userId}`, - ); - res.status(Status.NO_CONTENT).send(); - return; - } - - userService - .restartGoogleCalendarSync(userId, { force: true }) - .catch((err) => - logger.error( - `Something went wrong recovering Google calendars for user: ${userId}`, - err, - ), - ); + await userMetadataService.assessGoogleMetadata(userId); res.status(Status.NO_CONTENT).send(); }; diff --git a/packages/backend/src/sync/services/sync.service.ts b/packages/backend/src/sync/services/sync.service.ts index f478d516b..e93141ddb 100644 --- a/packages/backend/src/sync/services/sync.service.ts +++ b/packages/backend/src/sync/services/sync.service.ts @@ -154,6 +154,14 @@ class SyncService { }), ); + const affectedUsers = [...new Set(channels.map(({ user }) => user))]; + + await Promise.all( + affectedUsers.map((userId) => + userMetadataService.assessGoogleMetadata(userId), + ), + ); + return deleted.some((d) => d); } diff --git a/packages/backend/src/user/services/user-metadata.service.test.ts b/packages/backend/src/user/services/user-metadata.service.test.ts index 9b03eaaee..15cbcbeb7 100644 --- a/packages/backend/src/user/services/user-metadata.service.test.ts +++ b/packages/backend/src/user/services/user-metadata.service.test.ts @@ -1,15 +1,19 @@ +import { UserMetadataServiceDriver } from "@backend/__tests__/drivers/user-metadata.service.driver"; import { UserDriver } from "@backend/__tests__/drivers/user.driver"; +import { UtilDriver } from "@backend/__tests__/drivers/util.driver"; import { cleanupCollections, cleanupTestDb, setupTestDb, } from "@backend/__tests__/helpers/mock.db.setup"; import { initSupertokens } from "@backend/common/middleware/supertokens.middleware"; -import userMetadataService from "@backend/user/services/user-metadata.service"; +import userService from "@backend/user/services/user.service"; describe("UserMetadataService", () => { + const driver = new UserMetadataServiceDriver(); + beforeAll(initSupertokens); - beforeEach(setupTestDb); + beforeAll(setupTestDb); beforeEach(cleanupCollections); afterAll(cleanupTestDb); @@ -18,14 +22,14 @@ describe("UserMetadataService", () => { const user = await UserDriver.createUser(); const userId = user._id.toString(); - const metadata = await userMetadataService.updateUserMetadata({ + const metadata = await driver.updateUserMetadata({ userId, data: { sync: { importGCal: "restart" } }, }); expect(metadata.sync?.importGCal).toBe("restart"); - const persisted = await userMetadataService.fetchUserMetadata(userId); + const persisted = await driver.fetchUserMetadata(userId); expect(persisted.sync?.importGCal).toBe("restart"); }); @@ -36,34 +40,115 @@ describe("UserMetadataService", () => { const user = await UserDriver.createUser(); const userId = user._id.toString(); - await userMetadataService.updateUserMetadata({ + await driver.updateUserMetadata({ userId, data: { sync: { importGCal: "restart" } }, }); - const metadata = await userMetadataService.fetchUserMetadata(userId); + const metadata = await driver.fetchUserMetadata(userId); expect(metadata.sync?.importGCal).toBe("restart"); }); - it("enriches metadata with hasRefreshToken = true when user has refresh token", async () => { - const user = await UserDriver.createUser(); + it("returns not_connected when the user never connected Google", async () => { + const user = await UserDriver.createUser({ withGoogle: false }); const userId = user._id.toString(); - const metadata = await userMetadataService.fetchUserMetadata(userId); + const metadata = await driver.fetchUserMetadata(userId); - expect(metadata.google?.hasRefreshToken).toBe(true); + expect(metadata.google).toMatchObject({ + hasRefreshToken: false, + connectionStatus: "not_connected", + syncStatus: "none", + }); }); - it("enriches metadata with hasRefreshToken = false when user has no refresh token", async () => { + it("returns reconnect_required when the refresh token is missing", async () => { const user = await UserDriver.createUser({ withGoogleRefreshToken: false, }); const userId = user._id.toString(); - const metadata = await userMetadataService.fetchUserMetadata(userId); + const metadata = await driver.fetchUserMetadata(userId); + + expect(metadata.google).toMatchObject({ + hasRefreshToken: false, + connectionStatus: "reconnect_required", + syncStatus: "none", + }); + }); + + it("returns healthy when the account is connected and sync state is healthy", async () => { + const { user } = await UtilDriver.setupTestUser(); + const userId = user._id.toString(); + + const metadata = await driver.fetchUserMetadata(userId); + + expect(metadata.google).toMatchObject({ + hasRefreshToken: true, + connectionStatus: "connected", + syncStatus: "healthy", + }); + }); + + it("schedules repair and returns repairing when connected sync state is broken", async () => { + const user = await UserDriver.createUser(); + const userId = user._id.toString(); + const restartSpy = jest + .spyOn(userService, "restartGoogleCalendarSync") + .mockResolvedValue(); + + const metadata = await driver.fetchUserMetadata(userId); + + expect(metadata.google).toMatchObject({ + hasRefreshToken: true, + connectionStatus: "connected", + syncStatus: "repairing", + }); + expect(restartSpy).toHaveBeenCalledWith(userId, { force: true }); + + restartSpy.mockRestore(); + }); + + it("returns attention after a repair failed", async () => { + const user = await UserDriver.createUser(); + const userId = user._id.toString(); + + await driver.updateUserMetadata({ + userId, + data: { sync: { importGCal: "errored" } }, + }); + + const metadata = await driver.fetchUserMetadata(userId); + + expect(metadata.google).toMatchObject({ + hasRefreshToken: true, + connectionStatus: "connected", + syncStatus: "attention", + }); + }); + + it("does not schedule duplicate repairs when an import is already running", async () => { + const user = await UserDriver.createUser(); + const userId = user._id.toString(); + const restartSpy = jest + .spyOn(userService, "restartGoogleCalendarSync") + .mockResolvedValue(); + + await driver.updateUserMetadata({ + userId, + data: { sync: { importGCal: "importing" } }, + }); + + const metadata = await driver.fetchUserMetadata(userId); + + expect(metadata.google).toMatchObject({ + connectionStatus: "connected", + syncStatus: "repairing", + }); + expect(restartSpy).not.toHaveBeenCalled(); - expect(metadata.google?.hasRefreshToken).toBe(false); + restartSpy.mockRestore(); }); }); }); diff --git a/packages/backend/src/user/services/user-metadata.service.ts b/packages/backend/src/user/services/user-metadata.service.ts index 89fe2651c..77aa8c178 100644 --- a/packages/backend/src/user/services/user-metadata.service.ts +++ b/packages/backend/src/user/services/user-metadata.service.ts @@ -2,10 +2,186 @@ import mergeWith from "lodash.mergewith"; import SupertokensUserMetadata, { type JSONObject, } from "supertokens-node/recipe/usermetadata"; -import { type UserMetadata } from "@core/types/user.types"; +import { Logger } from "@core/logger/winston.logger"; +import { Resource_Sync } from "@core/types/sync.types"; +import { + type GoogleConnectionStatus, + type GoogleSyncStatus, + type Schema_User, + type UserMetadata, +} from "@core/types/user.types"; +import dayjs from "@core/util/date/dayjs"; +import mongoService from "@backend/common/services/mongo.service"; +import { getSync } from "@backend/sync/util/sync.queries"; import { findCompassUserBy } from "@backend/user/queries/user.queries"; +const logger = Logger("app:user.metadata.service"); + +type GoogleMetadataAssessment = { + hasRefreshToken: boolean; + connectionStatus: GoogleConnectionStatus; + syncStatus: GoogleSyncStatus; + scheduledRepair: boolean; +}; + class UserMetadataService { + private readonly pendingGoogleRepairs = new Map>(); + + private getStoredUserMetadata = async ( + userId: string, + userContext?: Record, + ): Promise => { + const { status, metadata } = await SupertokensUserMetadata.getUserMetadata( + userId, + userContext, + ); + + if (status !== "OK") throw new Error("Failed to fetch user metadata"); + + return metadata as UserMetadata; + }; + + private getGoogleConnectionStatus( + user?: Schema_User | null, + ): GoogleConnectionStatus { + const googleId = user?.google?.googleId; + const hasRefreshToken = Boolean(user?.google?.gRefreshToken); + + if (!googleId) return "not_connected"; + if (!hasRefreshToken) return "reconnect_required"; + + return "connected"; + } + + private hasPendingGoogleRepair(userId: string): boolean { + return this.pendingGoogleRepairs.has(userId); + } + + private async isGoogleSyncHealthy(userId: string): Promise { + const sync = await getSync({ userId }); + + if (!sync?.google) { + return false; + } + + const eventSyncs = sync.google.events ?? []; + const calendarListSyncs = sync.google.calendarlist ?? []; + + if (eventSyncs.length === 0 || calendarListSyncs.length === 0) { + return false; + } + + if (calendarListSyncs.some(({ nextSyncToken }) => !nextSyncToken)) { + return false; + } + + if (eventSyncs.some(({ nextSyncToken }) => !nextSyncToken)) { + return false; + } + + const activeWatchCalendarIds = new Set( + (await mongoService.watch.find({ user: userId }).toArray()) + .filter(({ expiration }) => dayjs(expiration).isAfter(dayjs())) + .map(({ gCalendarId }) => gCalendarId), + ); + + if (!activeWatchCalendarIds.has(Resource_Sync.CALENDAR)) { + return false; + } + + return eventSyncs.every(({ gCalendarId }) => + activeWatchCalendarIds.has(gCalendarId), + ); + } + + private scheduleGoogleRepair(userId: string): boolean { + if (this.hasPendingGoogleRepair(userId)) { + return false; + } + + const repair = import("@backend/user/services/user.service") + .then(({ default: userService }) => + userService.restartGoogleCalendarSync(userId, { force: true }), + ) + .catch((err) => { + logger.error( + `Failed to schedule Google Calendar repair for user: ${userId}`, + err, + ); + }) + .finally(() => { + this.pendingGoogleRepairs.delete(userId); + }); + + this.pendingGoogleRepairs.set(userId, repair); + + return true; + } + + assessGoogleMetadata = async ( + userId: string, + metadata?: UserMetadata, + ): Promise => { + const storedMetadata = + metadata ?? (await this.getStoredUserMetadata(userId)); + const user = await findCompassUserBy("_id", userId); + const hasRefreshToken = Boolean(user?.google?.gRefreshToken); + const connectionStatus = this.getGoogleConnectionStatus(user); + + if (connectionStatus !== "connected") { + return { + hasRefreshToken, + connectionStatus, + syncStatus: "none", + scheduledRepair: false, + }; + } + + const importStatus = storedMetadata.sync?.importGCal; + + if ( + importStatus === "importing" || + importStatus === "restart" || + this.hasPendingGoogleRepair(userId) + ) { + return { + hasRefreshToken, + connectionStatus, + syncStatus: "repairing", + scheduledRepair: false, + }; + } + + const isHealthy = await this.isGoogleSyncHealthy(userId); + + if (isHealthy) { + return { + hasRefreshToken, + connectionStatus, + syncStatus: "healthy", + scheduledRepair: false, + }; + } + + if (importStatus === "errored") { + return { + hasRefreshToken, + connectionStatus, + syncStatus: "attention", + scheduledRepair: false, + }; + } + + const scheduledRepair = this.scheduleGoogleRepair(userId); + + return { + hasRefreshToken, + connectionStatus, + syncStatus: scheduledRepair ? "repairing" : "attention", + scheduledRepair, + }; + }; + /* * updateUserMetadata * @@ -21,7 +197,7 @@ class UserMetadataService { userId: string; data: Partial; }): Promise => { - const value = await this.fetchUserMetadata(userId); + const value = await this.getStoredUserMetadata(userId); const update = mergeWith(value, data); const { status, metadata } = @@ -36,24 +212,16 @@ class UserMetadataService { userId: string, userContext?: Record, ): Promise => { - const { status, metadata } = await SupertokensUserMetadata.getUserMetadata( - userId, - userContext, - ); - - if (status !== "OK") throw new Error("Failed to fetch user metadata"); - - // Enrich with Google token status - const user = await findCompassUserBy("_id", userId); - const hasRefreshToken = Boolean(user?.google?.gRefreshToken); - - const typedMetadata = metadata as UserMetadata; + const metadata = await this.getStoredUserMetadata(userId, userContext); + const google = await this.assessGoogleMetadata(userId, metadata); return { - ...typedMetadata, + ...metadata, google: { - ...typedMetadata.google, - hasRefreshToken, + ...metadata.google, + hasRefreshToken: google.hasRefreshToken, + connectionStatus: google.connectionStatus, + syncStatus: google.syncStatus, }, }; }; diff --git a/packages/backend/src/user/services/user.service.ts b/packages/backend/src/user/services/user.service.ts index b00562c05..4c8a7b1d1 100644 --- a/packages/backend/src/user/services/user.service.ts +++ b/packages/backend/src/user/services/user.service.ts @@ -266,7 +266,7 @@ class UserService { try { webSocketServer.handleImportGCalStart(userId); - const userMeta = await userMetadataService.fetchUserMetadata(userId); + const userMeta = await this.fetchUserMetadata(userId); const importStatus = userMeta.sync?.importGCal; const isImporting = importStatus === "importing"; const proceed = isForce ? !isImporting : shouldImportGCal(userMeta); diff --git a/packages/core/src/types/user.types.ts b/packages/core/src/types/user.types.ts index 816b65a49..19e4fc025 100644 --- a/packages/core/src/types/user.types.ts +++ b/packages/core/src/types/user.types.ts @@ -17,6 +17,11 @@ export interface Schema_User { } type SyncStatus = "importing" | "errored" | "completed" | "restart" | null; +export type GoogleConnectionStatus = + | "not_connected" + | "connected" + | "reconnect_required"; +export type GoogleSyncStatus = "healthy" | "repairing" | "attention" | "none"; export interface UserMetadata extends SupertokensUserMetadata.JSONObject { skipOnboarding?: boolean; @@ -26,6 +31,8 @@ export interface UserMetadata extends SupertokensUserMetadata.JSONObject { }; google?: { hasRefreshToken?: boolean; + connectionStatus?: GoogleConnectionStatus; + syncStatus?: GoogleSyncStatus; }; } diff --git a/packages/web/src/auth/google/google.auth.util.test.ts b/packages/web/src/auth/google/google.auth.util.test.ts index 31a63e143..f3ede1905 100644 --- a/packages/web/src/auth/google/google.auth.util.test.ts +++ b/packages/web/src/auth/google/google.auth.util.test.ts @@ -1,5 +1,6 @@ import { toast } from "react-toastify"; import { Origin } from "@core/constants/core.constants"; +import { FETCH_USER_METADATA } from "@core/constants/websocket.constants"; import { clearGoogleRevokedState, isGoogleRevoked, @@ -9,6 +10,7 @@ import { GOOGLE_REVOKED_TOAST_ID } from "@web/common/constants/toast.constants"; import { syncLocalEventsToCloud } from "@web/common/utils/sync/local-event-sync.util"; import { type SignInUpInput } from "@web/components/oauth/ouath.types"; import { authSlice } from "@web/ducks/auth/slices/auth.slice"; +import { userMetadataSlice } from "@web/ducks/auth/slices/user-metadata.slice"; import { Sync_AsyncStateContextReason } from "@web/ducks/events/context/sync.context"; import { eventsEntitiesSlice } from "@web/ducks/events/slices/event.slice"; import { @@ -35,6 +37,13 @@ jest.mock("@web/store", () => ({ dispatch: jest.fn(), }, })); +jest.mock("@web/socket/client/socket.client", () => ({ + socket: { + connected: true, + emit: jest.fn(), + }, + reconnect: jest.fn(), +})); const mockAuthApi = AuthApi as jest.Mocked; const mockSyncLocalEventsToCloud = @@ -160,6 +169,9 @@ describe("google-auth.util", () => { expect(store.dispatch).toHaveBeenCalledWith( authSlice.actions.resetAuth(), ); + expect(store.dispatch).toHaveBeenCalledWith( + userMetadataSlice.actions.clear(), + ); expect(store.dispatch).toHaveBeenCalledWith( importGCalSlice.actions.importing(false), ); @@ -176,6 +188,14 @@ describe("google-auth.util", () => { ); }); + it("refreshes user metadata when the socket is connected", () => { + const { socket } = require("@web/socket/client/socket.client"); + + handleGoogleRevoked(); + + expect(socket.emit).toHaveBeenCalledWith(FETCH_USER_METADATA); + }); + it("marks Google as revoked in session state", () => { expect(isGoogleRevoked()).toBe(false); @@ -190,7 +210,7 @@ describe("google-auth.util", () => { handleGoogleRevoked(); expect(toast.error).not.toHaveBeenCalled(); - expect(store.dispatch).toHaveBeenCalledTimes(5); + expect(store.dispatch).toHaveBeenCalledTimes(6); }); }); }); diff --git a/packages/web/src/auth/google/google.auth.util.ts b/packages/web/src/auth/google/google.auth.util.ts index 64d22ba62..ab795f080 100644 --- a/packages/web/src/auth/google/google.auth.util.ts +++ b/packages/web/src/auth/google/google.auth.util.ts @@ -1,17 +1,20 @@ import { toast } from "react-toastify"; import { Origin } from "@core/constants/core.constants"; +import { FETCH_USER_METADATA } from "@core/constants/websocket.constants"; import { markGoogleAsRevoked } from "@web/auth/google/google.auth.state"; 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 { type SignInUpInput } from "@web/components/oauth/ouath.types"; import { authSlice } from "@web/ducks/auth/slices/auth.slice"; +import { userMetadataSlice } from "@web/ducks/auth/slices/user-metadata.slice"; import { Sync_AsyncStateContextReason } from "@web/ducks/events/context/sync.context"; import { eventsEntitiesSlice } from "@web/ducks/events/slices/event.slice"; import { importGCalSlice, triggerFetch, } from "@web/ducks/events/slices/sync.slice"; +import { reconnect, socket } from "@web/socket/client/socket.client"; import { store } from "@web/store"; export interface AuthenticateResult { @@ -53,6 +56,7 @@ export const handleGoogleRevoked = () => { markGoogleAsRevoked(); store.dispatch(authSlice.actions.resetAuth()); + store.dispatch(userMetadataSlice.actions.clear()); store.dispatch(importGCalSlice.actions.importing(false)); store.dispatch(importGCalSlice.actions.setIsImportPending(false)); @@ -64,6 +68,13 @@ export const handleGoogleRevoked = () => { store.dispatch( triggerFetch({ reason: Sync_AsyncStateContextReason.GOOGLE_REVOKED }), ); + + if (socket.connected) { + socket.emit(FETCH_USER_METADATA); + return; + } + + reconnect(); }; /** diff --git a/packages/web/src/auth/hooks/oauth/useConnectGoogle.test.ts b/packages/web/src/auth/hooks/oauth/useConnectGoogle.test.ts index 7c5ff4888..052ee23c3 100644 --- a/packages/web/src/auth/hooks/oauth/useConnectGoogle.test.ts +++ b/packages/web/src/auth/hooks/oauth/useConnectGoogle.test.ts @@ -1,23 +1,21 @@ import { renderHook } from "@testing-library/react"; -import * as googleAuthState from "@web/auth/google/google.auth.state"; import { useConnectGoogle } from "@web/auth/hooks/oauth/useConnectGoogle"; import { useGoogleAuth } from "@web/auth/hooks/oauth/useGoogleAuth"; -import { useSession } from "@web/auth/hooks/session/useSession"; import { settingsSlice } from "@web/ducks/settings/slices/settings.slice"; -import { useAppDispatch } from "@web/store/store.hooks"; +import { useAppDispatch, useAppSelector } from "@web/store/store.hooks"; -jest.mock("@web/auth/google/google.auth.state"); -jest.mock("@web/auth/hooks/session/useSession"); jest.mock("@web/auth/hooks/oauth/useGoogleAuth"); jest.mock("@web/store/store.hooks"); -const mockUseSession = useSession as jest.MockedFunction; const mockUseGoogleAuth = useGoogleAuth as jest.MockedFunction< typeof useGoogleAuth >; const mockUseAppDispatch = useAppDispatch as jest.MockedFunction< typeof useAppDispatch >; +const mockUseAppSelector = useAppSelector as jest.MockedFunction< + typeof useAppSelector +>; describe("useConnectGoogle", () => { const mockDispatch = jest.fn(); @@ -29,52 +27,69 @@ describe("useConnectGoogle", () => { mockUseGoogleAuth.mockReturnValue({ login: mockLogin, }); - mockUseSession.mockReturnValue({ - authenticated: false, - setAuthenticated: jest.fn(), - }); - // Default: Google not revoked - jest.spyOn(googleAuthState, "isGoogleRevoked").mockReturnValue(false); + mockUseAppSelector.mockReturnValue(undefined); }); - it("returns true when Google Calendar is connected", () => { - mockUseSession.mockReturnValue({ - authenticated: true, - setAuthenticated: jest.fn(), + it("returns connect state when metadata is missing", () => { + const { result } = renderHook(() => useConnectGoogle()); + + expect(result.current.label).toBe("Connect Google Calendar"); + expect(result.current.isDisabled).toBe(false); + }); + + it("returns connected state when metadata is healthy", () => { + mockUseAppSelector.mockReturnValue({ + connectionStatus: "connected", + syncStatus: "healthy", }); const { result } = renderHook(() => useConnectGoogle()); - expect(result.current.isGoogleCalendarConnected).toBe(true); + expect(result.current.label).toBe("Google Calendar Connected"); + expect(result.current.onSelect).toBeUndefined(); }); - it("returns false when Google Calendar is not connected", () => { - mockUseSession.mockReturnValue({ - authenticated: false, - setAuthenticated: jest.fn(), + it("returns reconnect state when refresh token is missing", () => { + mockUseAppSelector.mockReturnValue({ + connectionStatus: "reconnect_required", + syncStatus: "none", }); const { result } = renderHook(() => useConnectGoogle()); - expect(result.current.isGoogleCalendarConnected).toBe(false); + expect(result.current.label).toBe("Reconnect Google Calendar"); + result.current.onSelect?.(); + + expect(mockLogin).toHaveBeenCalled(); + expect(mockDispatch).toHaveBeenCalledWith( + settingsSlice.actions.closeCmdPalette(), + ); }); - it("returns false when authenticated but Google is revoked", () => { - mockUseSession.mockReturnValue({ - authenticated: true, - setAuthenticated: jest.fn(), + it("returns syncing state while repair is running", () => { + mockUseAppSelector.mockReturnValue({ + connectionStatus: "connected", + syncStatus: "repairing", }); - jest.spyOn(googleAuthState, "isGoogleRevoked").mockReturnValue(true); const { result } = renderHook(() => useConnectGoogle()); - expect(result.current.isGoogleCalendarConnected).toBe(false); + expect(result.current.label).toBe("Syncing Google Calendar..."); + expect(result.current.isDisabled).toBe(true); + expect(result.current.onSelect).toBeUndefined(); }); - it("logs in and closes the command palette on connect", () => { + it("returns repair state when sync needs attention", () => { + mockUseAppSelector.mockReturnValue({ + connectionStatus: "connected", + syncStatus: "attention", + }); + const { result } = renderHook(() => useConnectGoogle()); - result.current.onConnectGoogleCalendar(); + expect(result.current.label).toBe("Repair Google Calendar"); + + result.current.onSelect?.(); expect(mockLogin).toHaveBeenCalled(); expect(mockDispatch).toHaveBeenCalledWith( diff --git a/packages/web/src/auth/hooks/oauth/useConnectGoogle.ts b/packages/web/src/auth/hooks/oauth/useConnectGoogle.ts index 336890904..af90f1b5a 100644 --- a/packages/web/src/auth/hooks/oauth/useConnectGoogle.ts +++ b/packages/web/src/auth/hooks/oauth/useConnectGoogle.ts @@ -1,25 +1,62 @@ import { useCallback } from "react"; -import { isGoogleRevoked } from "@web/auth/google/google.auth.state"; import { useGoogleAuth } from "@web/auth/hooks/oauth/useGoogleAuth"; -import { useSession } from "@web/auth/hooks/session/useSession"; +import { selectGoogleMetadata } from "@web/ducks/auth/selectors/user-metadata.selectors"; import { settingsSlice } from "@web/ducks/settings/slices/settings.slice"; -import { useAppDispatch } from "@web/store/store.hooks"; +import { useAppDispatch, useAppSelector } from "@web/store/store.hooks"; export const useConnectGoogle = () => { const dispatch = useAppDispatch(); - const { authenticated } = useSession(); + const googleMetadata = useAppSelector(selectGoogleMetadata); const { login } = useGoogleAuth(); - const onConnectGoogleCalendar = useCallback(() => { + const onOpenGoogleAuth = useCallback(() => { login(); dispatch(settingsSlice.actions.closeCmdPalette()); }, [dispatch, login]); - // Google is only truly connected if authenticated AND not revoked - const isGoogleCalendarConnected = authenticated && !isGoogleRevoked(); + const connectionStatus = googleMetadata?.connectionStatus ?? "not_connected"; + const syncStatus = googleMetadata?.syncStatus ?? "none"; + + if (connectionStatus === "connected" && syncStatus === "repairing") { + return { + label: "Syncing Google Calendar...", + icon: "ArrowPathIcon", + isDisabled: true, + onSelect: undefined, + }; + } + + if (connectionStatus === "connected" && syncStatus === "attention") { + return { + label: "Repair Google Calendar", + icon: "ArrowPathIcon", + isDisabled: false, + onSelect: onOpenGoogleAuth, + }; + } + + if (connectionStatus === "connected" && syncStatus === "healthy") { + return { + label: "Google Calendar Connected", + icon: "CheckCircleIcon", + isDisabled: false, + onSelect: undefined, + }; + } + + if (connectionStatus === "reconnect_required") { + return { + label: "Reconnect Google Calendar", + icon: "ArrowPathIcon", + isDisabled: false, + onSelect: onOpenGoogleAuth, + }; + } return { - isGoogleCalendarConnected, - onConnectGoogleCalendar, + label: "Connect Google Calendar", + icon: "CloudArrowUpIcon", + isDisabled: false, + onSelect: onOpenGoogleAuth, }; }; diff --git a/packages/web/src/auth/session/SessionProvider.tsx b/packages/web/src/auth/session/SessionProvider.tsx index 82cbf694a..4f2667603 100644 --- a/packages/web/src/auth/session/SessionProvider.tsx +++ b/packages/web/src/auth/session/SessionProvider.tsx @@ -18,7 +18,9 @@ import { markUserAsAuthenticated } from "@web/auth/state/auth.state.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 { userMetadataSlice } from "@web/ducks/auth/slices/user-metadata.slice"; import * as socket from "@web/socket/provider/SocketProvider"; +import { store } from "@web/store"; import { type CompassSession } from "./session.types"; SuperTokens.init({ @@ -96,6 +98,7 @@ export function sessionInit() { socket.reconnect(); break; case "SIGN_OUT": + store.dispatch(userMetadataSlice.actions.clear()); socket.disconnect(); break; } diff --git a/packages/web/src/ducks/auth/selectors/user-metadata.selectors.ts b/packages/web/src/ducks/auth/selectors/user-metadata.selectors.ts new file mode 100644 index 000000000..f405cb481 --- /dev/null +++ b/packages/web/src/ducks/auth/selectors/user-metadata.selectors.ts @@ -0,0 +1,7 @@ +import { type RootState } from "@web/store"; + +export const selectUserMetadata = (state: RootState) => + state.userMetadata.current; + +export const selectGoogleMetadata = (state: RootState) => + selectUserMetadata(state)?.google; diff --git a/packages/web/src/ducks/auth/slices/user-metadata.slice.ts b/packages/web/src/ducks/auth/slices/user-metadata.slice.ts new file mode 100644 index 000000000..0a07cbe23 --- /dev/null +++ b/packages/web/src/ducks/auth/slices/user-metadata.slice.ts @@ -0,0 +1,23 @@ +import { type PayloadAction, createSlice } from "@reduxjs/toolkit"; +import { type UserMetadata } from "@core/types/user.types"; + +interface UserMetadataState { + current: UserMetadata | null; +} + +const initialState: UserMetadataState = { + current: null, +}; + +export const userMetadataSlice = createSlice({ + name: "userMetadata", + initialState, + reducers: { + set: (state, action: PayloadAction) => { + state.current = action.payload; + }, + clear: (state) => { + state.current = null; + }, + }, +}); diff --git a/packages/web/src/socket/hooks/useGcalSync.test.ts b/packages/web/src/socket/hooks/useGcalSync.test.ts index 1e410e315..840ad549a 100644 --- a/packages/web/src/socket/hooks/useGcalSync.test.ts +++ b/packages/web/src/socket/hooks/useGcalSync.test.ts @@ -6,6 +6,7 @@ import { IMPORT_GCAL_START, USER_METADATA, } from "@core/constants/websocket.constants"; +import { userMetadataSlice } from "@web/ducks/auth/slices/user-metadata.slice"; import { selectImporting, selectIsImportPending, @@ -48,9 +49,13 @@ jest.mock("@web/ducks/events/slices/sync.slice", () => ({ }, triggerFetch: jest.fn(), })); -// Mock shouldImportGCal util -jest.mock("@core/util/event/event.util", () => ({ - shouldImportGCal: jest.fn(() => false), +jest.mock("@web/ducks/auth/slices/user-metadata.slice", () => ({ + userMetadataSlice: { + actions: { + set: jest.fn((payload) => ({ type: "userMetadata/set", payload })), + clear: jest.fn(() => ({ type: "userMetadata/clear" })), + }, + }, })); jest.mock("react-toastify", () => ({ toast: { @@ -140,6 +145,9 @@ describe("useGcalSync", () => { // Simulate socket reconnecting while import is still running metadataHandler?.({ sync: { importGCal: "importing" } }); + expect(mockDispatch).toHaveBeenCalledWith( + userMetadataSlice.actions.set({ sync: { importGCal: "importing" } }), + ); expect(mockDispatch).toHaveBeenCalledWith( importGCalSlice.actions.importing(true), ); @@ -160,6 +168,9 @@ describe("useGcalSync", () => { metadataHandler?.({ sync: { importGCal: "completed" } }); + expect(mockDispatch).toHaveBeenCalledWith( + userMetadataSlice.actions.set({ sync: { importGCal: "completed" } }), + ); expect(mockDispatch).toHaveBeenCalledWith( importGCalSlice.actions.importing(false), ); @@ -185,6 +196,9 @@ describe("useGcalSync", () => { metadataHandler?.({ sync: { importGCal: "errored" } }); + expect(mockDispatch).toHaveBeenCalledWith( + userMetadataSlice.actions.set({ sync: { importGCal: "errored" } }), + ); expect(mockDispatch).toHaveBeenCalledWith( importGCalSlice.actions.importing(false), ); @@ -194,10 +208,7 @@ describe("useGcalSync", () => { expect(triggerFetch).not.toHaveBeenCalled(); }); - it("requests an import when metadata says one is needed", () => { - const { shouldImportGCal } = require("@core/util/event/event.util"); - shouldImportGCal.mockReturnValue(true); - + it("requests an import when metadata says restart is needed", () => { let metadataHandler: ((metadata: unknown) => void) | undefined; (socket.on as jest.Mock).mockImplementation((event, handler) => { if (event === USER_METADATA) { @@ -213,6 +224,24 @@ describe("useGcalSync", () => { importGCalSlice.actions.request(undefined as never), ); }); + + it("does not auto-request an import when metadata says errored", () => { + let metadataHandler: ((metadata: unknown) => void) | undefined; + (socket.on as jest.Mock).mockImplementation((event, handler) => { + if (event === USER_METADATA) { + metadataHandler = handler; + } + }); + + renderHook(() => useGcalSync()); + + metadataHandler?.({ + sync: { importGCal: "errored" }, + google: { connectionStatus: "connected", syncStatus: "attention" }, + }); + + expect(importGCalSlice.actions.request).not.toHaveBeenCalled(); + }); }); describe("IMPORT_GCAL_START", () => { diff --git a/packages/web/src/socket/hooks/useGcalSync.ts b/packages/web/src/socket/hooks/useGcalSync.ts index e9f096284..02242be3b 100644 --- a/packages/web/src/socket/hooks/useGcalSync.ts +++ b/packages/web/src/socket/hooks/useGcalSync.ts @@ -7,8 +7,8 @@ import { USER_METADATA, } from "@core/constants/websocket.constants"; import { type UserMetadata } from "@core/types/user.types"; -import { shouldImportGCal } from "@core/util/event/event.util"; import { handleGoogleRevoked } from "@web/auth/google/google.auth.util"; +import { userMetadataSlice } from "@web/ducks/auth/slices/user-metadata.slice"; import { Sync_AsyncStateContextReason } from "@web/ducks/events/context/sync.context"; import { selectIsImportPending } from "@web/ducks/events/selectors/sync.selector"; import { @@ -84,9 +84,11 @@ export const useGcalSync = () => { const onMetadataFetch = useCallback( (metadata: UserMetadata) => { - const importGcal = shouldImportGCal(metadata); const importStatus = metadata.sync?.importGCal; const isBackendImporting = importStatus === "importing"; + const shouldAutoImport = importStatus === "restart"; + + dispatch(userMetadataSlice.actions.set(metadata)); if (isImportPendingRef.current) { if (isBackendImporting) { @@ -109,7 +111,7 @@ export const useGcalSync = () => { // Normal case (not in post-auth flow) - sync state with backend onImportStart(isBackendImporting); - if (importGcal) { + if (shouldAutoImport) { dispatch(importGCalSlice.actions.request(undefined as never)); } }, diff --git a/packages/web/src/store/reducers.ts b/packages/web/src/store/reducers.ts index 804ebef4c..2fad00451 100644 --- a/packages/web/src/store/reducers.ts +++ b/packages/web/src/store/reducers.ts @@ -1,5 +1,6 @@ import { combineReducers } from "redux"; import { authSlice } from "@web/ducks/auth/slices/auth.slice"; +import { userMetadataSlice } from "@web/ducks/auth/slices/user-metadata.slice"; import { getDayEventsSlice } from "@web/ducks/events/slices/day.slice"; import { draftSlice } from "@web/ducks/events/slices/draft.slice"; import { @@ -42,5 +43,6 @@ export const reducers = { events: eventsReducer, settings: settingsSlice.reducer, sync: syncReducer, + userMetadata: userMetadataSlice.reducer, view: viewSlice.reducer, }; diff --git a/packages/web/src/views/CmdPalette/CmdPalette.tsx b/packages/web/src/views/CmdPalette/CmdPalette.tsx index 64dbb9490..54c1bb89b 100644 --- a/packages/web/src/views/CmdPalette/CmdPalette.tsx +++ b/packages/web/src/views/CmdPalette/CmdPalette.tsx @@ -41,8 +41,7 @@ const CmdPalette = ({ const open = useAppSelector(selectIsCmdPaletteOpen); const [page] = useState<"root" | "projects">("root"); const [search, setSearch] = useState(""); - const { isGoogleCalendarConnected, onConnectGoogleCalendar } = - useConnectGoogle(); + const { label, icon, isDisabled, onSelect } = useConnectGoogle(); const authCmdItems = useAuthCmdItems(); const handleCreateSomedayDraft = async ( @@ -138,15 +137,10 @@ const CmdPalette = ({ items: [ { id: "connect-google-calendar", - children: isGoogleCalendarConnected - ? "Google Calendar Connected" - : "Connect Google Calendar", - icon: isGoogleCalendarConnected - ? "CheckCircleIcon" - : "CloudArrowUpIcon", - onClick: isGoogleCalendarConnected - ? undefined - : onConnectGoogleCalendar, + children: label, + icon, + disabled: isDisabled, + onClick: onSelect, }, ...authCmdItems, { diff --git a/packages/web/src/views/Day/components/DayCmdPalette.test.tsx b/packages/web/src/views/Day/components/DayCmdPalette.test.tsx index 20dbf77e8..c50c69cea 100644 --- a/packages/web/src/views/Day/components/DayCmdPalette.test.tsx +++ b/packages/web/src/views/Day/components/DayCmdPalette.test.tsx @@ -3,7 +3,6 @@ import { fireEvent, screen } from "@testing-library/react"; import userEvent from "@testing-library/user-event"; import { render } from "@web/__tests__/__mocks__/mock.render"; import * as useGoogleAuthModule from "@web/auth/hooks/oauth/useGoogleAuth"; -import * as useSessionModule from "@web/auth/hooks/session/useSession"; import { ROOT_ROUTES } from "@web/common/constants/routes"; import { keyPressed$ } from "@web/common/utils/dom/event-emitter.util"; import * as eventUtil from "@web/common/utils/event/event.util"; @@ -246,12 +245,7 @@ describe("DayCmdPalette", () => { }); }); - it("shows 'Connect Google Calendar' when not authenticated", async () => { - jest.spyOn(useSessionModule, "useSession").mockReturnValue({ - authenticated: false, - setAuthenticated: jest.fn(), - }); - + it("shows 'Connect Google Calendar' when metadata is missing", async () => { await act(() => render(, { state: { settings: { isCmdPaletteOpen: true } }, @@ -264,15 +258,20 @@ describe("DayCmdPalette", () => { ).not.toBeInTheDocument(); }); - it("shows 'Google Calendar Connected' when authenticated", async () => { - jest.spyOn(useSessionModule, "useSession").mockReturnValue({ - authenticated: true, - setAuthenticated: jest.fn(), - }); - + it("shows 'Google Calendar Connected' when metadata is healthy", async () => { await act(() => render(, { - state: { settings: { isCmdPaletteOpen: true } }, + state: { + settings: { isCmdPaletteOpen: true }, + userMetadata: { + current: { + google: { + connectionStatus: "connected", + syncStatus: "healthy", + }, + }, + }, + }, }), ); @@ -282,20 +281,27 @@ describe("DayCmdPalette", () => { ).not.toBeInTheDocument(); }); - it("triggers login when 'Connect Google Calendar' is clicked and not authenticated", async () => { - jest.spyOn(useSessionModule, "useSession").mockReturnValue({ - authenticated: false, - setAuthenticated: jest.fn(), - }); - + it("triggers login when reconnect is required", async () => { const user = userEvent.setup(); await act(() => render(, { - state: { settings: { isCmdPaletteOpen: true } }, + state: { + settings: { isCmdPaletteOpen: true }, + userMetadata: { + current: { + google: { + connectionStatus: "reconnect_required", + syncStatus: "none", + }, + }, + }, + }, }), ); - await act(() => user.click(screen.getByText("Connect Google Calendar"))); + await act(() => + user.click(screen.getByText("Reconnect Google Calendar")), + ); expect(mockLogin).toHaveBeenCalled(); expect(mockDispatch).toHaveBeenCalledWith( @@ -303,24 +309,51 @@ describe("DayCmdPalette", () => { ); }); - it("does not trigger login when 'Google Calendar Connected' is clicked", async () => { - jest.spyOn(useSessionModule, "useSession").mockReturnValue({ - authenticated: true, - setAuthenticated: jest.fn(), - }); - - const user = userEvent.setup(); + it("shows syncing state while repair is running", async () => { await act(() => render(, { - state: { settings: { isCmdPaletteOpen: true } }, + state: { + settings: { isCmdPaletteOpen: true }, + userMetadata: { + current: { + google: { + connectionStatus: "connected", + syncStatus: "repairing", + }, + }, + }, + }, }), ); + const button = screen + .getByText("Syncing Google Calendar...") + .closest("button"); + + expect(button).toBeDisabled(); + }); + + it("shows repair action when sync needs attention", async () => { + const user = userEvent.setup(); await act(() => - user.click(screen.getByText("Google Calendar Connected")), + render(, { + state: { + settings: { isCmdPaletteOpen: true }, + userMetadata: { + current: { + google: { + connectionStatus: "connected", + syncStatus: "attention", + }, + }, + }, + }, + }), ); - expect(mockLogin).not.toHaveBeenCalled(); + await act(() => user.click(screen.getByText("Repair Google Calendar"))); + + expect(mockLogin).toHaveBeenCalled(); }); }); }); diff --git a/packages/web/src/views/Day/components/DayCmdPalette.tsx b/packages/web/src/views/Day/components/DayCmdPalette.tsx index ea31d754e..f74049215 100644 --- a/packages/web/src/views/Day/components/DayCmdPalette.tsx +++ b/packages/web/src/views/Day/components/DayCmdPalette.tsx @@ -25,8 +25,7 @@ export const DayCmdPalette = ({ onGoToToday }: DayCmdPaletteProps) => { const [page] = useState<"root">("root"); const [search, setSearch] = useState(""); const today = dayjs(); - const { isGoogleCalendarConnected, onConnectGoogleCalendar } = - useConnectGoogle(); + const { label, icon, isDisabled, onSelect } = useConnectGoogle(); const authCmdItems = useAuthCmdItems(); const filteredItems = filterItems( @@ -75,15 +74,10 @@ export const DayCmdPalette = ({ onGoToToday }: DayCmdPaletteProps) => { items: [ { id: "connect-google-calendar", - children: isGoogleCalendarConnected - ? "Google Calendar Connected" - : "Connect Google Calendar", - icon: isGoogleCalendarConnected - ? "CheckCircleIcon" - : "CloudArrowUpIcon", - onClick: isGoogleCalendarConnected - ? undefined - : onConnectGoogleCalendar, + children: label, + icon, + disabled: isDisabled, + onClick: onSelect, }, ...authCmdItems, { diff --git a/packages/web/src/views/Now/components/NowCmdPalette.test.tsx b/packages/web/src/views/Now/components/NowCmdPalette.test.tsx index ecb39e3e2..359a0beed 100644 --- a/packages/web/src/views/Now/components/NowCmdPalette.test.tsx +++ b/packages/web/src/views/Now/components/NowCmdPalette.test.tsx @@ -37,8 +37,10 @@ jest.mock("react-cmdk", () => { {children} ); - CommandPalette.ListItem = ({ children, onClick }: any) => ( - + CommandPalette.ListItem = ({ children, disabled, onClick }: any) => ( + ); CommandPalette.FreeSearchAction = () =>
No results
; @@ -61,16 +63,6 @@ jest.mock("@web/common/utils/dom/event-target-visibility.util", () => ({ onEventTargetVisibility: (cb: () => void) => () => cb(), })); -// Mock useSession for auth state tests -const mockSetAuthenticated = jest.fn(); -let mockAuthenticated = false; -jest.mock("@web/auth/hooks/session/useSession", () => ({ - useSession: () => ({ - authenticated: mockAuthenticated, - setAuthenticated: mockSetAuthenticated, - }), -})); - // Mock useGoogleAuth const mockLogin = jest.fn(); jest.mock("@web/auth/hooks/oauth/useGoogleAuth", () => ({ @@ -147,8 +139,7 @@ describe("NowCmdPalette", () => { mockLogin.mockClear(); }); - it("shows 'Connect Google Calendar' when not authenticated", () => { - mockAuthenticated = false; + it("shows 'Connect Google Calendar' when metadata is missing", () => { render(, { state: initialState }); expect(screen.getByText("Connect Google Calendar")).toBeInTheDocument(); @@ -157,9 +148,20 @@ describe("NowCmdPalette", () => { ).not.toBeInTheDocument(); }); - it("shows 'Google Calendar Connected' when authenticated", () => { - mockAuthenticated = true; - render(, { state: initialState }); + it("shows 'Google Calendar Connected' when metadata is healthy", () => { + render(, { + state: { + ...initialState, + userMetadata: { + current: { + google: { + connectionStatus: "connected", + syncStatus: "healthy", + }, + }, + }, + }, + }); expect(screen.getByText("Google Calendar Connected")).toBeInTheDocument(); expect( @@ -167,21 +169,64 @@ describe("NowCmdPalette", () => { ).not.toBeInTheDocument(); }); - it("triggers login when 'Connect Google Calendar' is clicked and not authenticated", async () => { - mockAuthenticated = false; - render(, { state: initialState }); + it("shows reconnect and triggers login when needed", async () => { + render(, { + state: { + ...initialState, + userMetadata: { + current: { + google: { + connectionStatus: "reconnect_required", + syncStatus: "none", + }, + }, + }, + }, + }); - act(() => fireEvent.click(screen.getByText("Connect Google Calendar"))); + act(() => fireEvent.click(screen.getByText("Reconnect Google Calendar"))); await waitFor(() => expect(mockLogin).toHaveBeenCalled()); }); - it("does not trigger login when 'Google Calendar Connected' is clicked", async () => { - mockAuthenticated = true; - render(, { state: initialState }); + it("shows syncing state and disables clicks while repairing", async () => { + render(, { + state: { + ...initialState, + userMetadata: { + current: { + google: { + connectionStatus: "connected", + syncStatus: "repairing", + }, + }, + }, + }, + }); - act(() => fireEvent.click(screen.getByText("Google Calendar Connected"))); + const button = screen.getByRole("button", { + name: "Syncing Google Calendar...", + }); + expect(button).toBeDisabled(); + }); - expect(mockLogin).not.toHaveBeenCalled(); + it("shows repair action when sync needs attention", async () => { + render(, { + state: { + ...initialState, + userMetadata: { + current: { + google: { + connectionStatus: "connected", + syncStatus: "attention", + }, + }, + }, + }, + }); + + act(() => fireEvent.click(screen.getByText("Repair Google Calendar"))); + + await waitFor(() => expect(mockLogin).toHaveBeenCalled()); }); }); }); diff --git a/packages/web/src/views/Now/components/NowCmdPalette.tsx b/packages/web/src/views/Now/components/NowCmdPalette.tsx index 06b4e6238..a33aaec28 100644 --- a/packages/web/src/views/Now/components/NowCmdPalette.tsx +++ b/packages/web/src/views/Now/components/NowCmdPalette.tsx @@ -16,8 +16,7 @@ export const NowCmdPalette = () => { const open = useAppSelector(selectIsCmdPaletteOpen); const [page] = useState<"root">("root"); const [search, setSearch] = useState(""); - const { isGoogleCalendarConnected, onConnectGoogleCalendar } = - useConnectGoogle(); + const { label, icon, isDisabled, onSelect } = useConnectGoogle(); const authCmdItems = useAuthCmdItems(); const filteredItems = filterItems( @@ -52,15 +51,10 @@ export const NowCmdPalette = () => { items: [ { id: "connect-google-calendar", - children: isGoogleCalendarConnected - ? "Google Calendar Connected" - : "Connect Google Calendar", - icon: isGoogleCalendarConnected - ? "CheckCircleIcon" - : "CloudArrowUpIcon", - onClick: isGoogleCalendarConnected - ? undefined - : onConnectGoogleCalendar, + children: label, + icon, + disabled: isDisabled, + onClick: onSelect, }, ...authCmdItems, { From 5f3a5d0ce704d42f5fcc56d623cbaf46e927dac1 Mon Sep 17 00:00:00 2001 From: Cursor Agent Date: Sat, 7 Mar 2026 09:48:12 +0000 Subject: [PATCH 02/24] fix(backend): guard stale watch metadata assessment errors Co-authored-by: Tyler Dane --- .../src/sync/services/sync.service.test.ts | 26 +++++++++++++++++++ .../backend/src/sync/services/sync.service.ts | 13 +++++++--- 2 files changed, 36 insertions(+), 3 deletions(-) diff --git a/packages/backend/src/sync/services/sync.service.test.ts b/packages/backend/src/sync/services/sync.service.test.ts index c9265ff84..06745dcd1 100644 --- a/packages/backend/src/sync/services/sync.service.test.ts +++ b/packages/backend/src/sync/services/sync.service.test.ts @@ -14,6 +14,7 @@ import { missingRefreshTokenError } from "@backend/__tests__/mocks.gcal/errors/e 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"; +import userMetadataService from "@backend/user/services/user-metadata.service"; const createWatch = async (user: string) => { const watch = WatchSchema.parse({ @@ -201,4 +202,29 @@ describe("SyncService", () => { expect(cleanupSpy).toHaveBeenCalledTimes(1); }); }); + + describe("cleanupStaleWatchChannel", () => { + it("returns deletion result even when metadata assessment fails", async () => { + const user = await UserDriver.createUser(); + const watch = await createWatch(user._id.toString()); + + jest.spyOn(syncService, "stopWatch").mockResolvedValue({ + channelId: watch._id.toString(), + resourceId: watch.resourceId, + }); + jest + .spyOn(userMetadataService, "assessGoogleMetadata") + .mockRejectedValue(new Error("metadata assessment failed")); + + await expect( + syncService.cleanupStaleWatchChannel({ + resource: Resource_Sync.EVENTS, + channelId: watch._id.toString(), + resourceId: watch.resourceId, + resourceState: XGoogleResourceState.EXISTS, + expiration: faker.date.future(), + }), + ).resolves.toBe(true); + }); + }); }); diff --git a/packages/backend/src/sync/services/sync.service.ts b/packages/backend/src/sync/services/sync.service.ts index e93141ddb..e9a9a4c95 100644 --- a/packages/backend/src/sync/services/sync.service.ts +++ b/packages/backend/src/sync/services/sync.service.ts @@ -157,9 +157,16 @@ class SyncService { const affectedUsers = [...new Set(channels.map(({ user }) => user))]; await Promise.all( - affectedUsers.map((userId) => - userMetadataService.assessGoogleMetadata(userId), - ), + affectedUsers.map(async (userId) => { + try { + await userMetadataService.assessGoogleMetadata(userId); + } catch (error) { + logger.error( + `Failed to assess Google metadata after stale watch cleanup for user: ${userId}`, + error, + ); + } + }), ); return deleted.some((d) => d); From c814d525cbc13b10afc08d3357cadd14c7e743a9 Mon Sep 17 00:00:00 2001 From: Cursor Agent Date: Sat, 7 Mar 2026 09:56:21 +0000 Subject: [PATCH 03/24] fix(backend): prevent concurrent sync by skipping assessment in importIncremental Co-authored-by: Tyler Dane --- .../backend/src/sync/services/sync.service.ts | 8 +++++++- .../src/user/services/user-metadata.service.ts | 18 ++++++++++++++++++ 2 files changed, 25 insertions(+), 1 deletion(-) diff --git a/packages/backend/src/sync/services/sync.service.ts b/packages/backend/src/sync/services/sync.service.ts index e9a9a4c95..d15e13f3f 100644 --- a/packages/backend/src/sync/services/sync.service.ts +++ b/packages/backend/src/sync/services/sync.service.ts @@ -321,7 +321,13 @@ class SyncService { try { webSocketServer.handleImportGCalStart(userId); - const userMeta = await userMetadataService.fetchUserMetadata(userId); + const userMeta = await userMetadataService.fetchUserMetadata( + userId, + undefined, + { + skipAssessment: true, + }, + ); const proceed = shouldDoIncrementalGCalSync(userMeta); if (!proceed) { diff --git a/packages/backend/src/user/services/user-metadata.service.ts b/packages/backend/src/user/services/user-metadata.service.ts index 77aa8c178..0b27620e0 100644 --- a/packages/backend/src/user/services/user-metadata.service.ts +++ b/packages/backend/src/user/services/user-metadata.service.ts @@ -211,8 +211,26 @@ class UserMetadataService { fetchUserMetadata = async ( userId: string, userContext?: Record, + options?: { skipAssessment?: boolean }, ): Promise => { const metadata = await this.getStoredUserMetadata(userId, userContext); + + if (options?.skipAssessment) { + const user = await findCompassUserBy("_id", userId); + const hasRefreshToken = Boolean(user?.google?.gRefreshToken); + const connectionStatus = this.getGoogleConnectionStatus(user); + + return { + ...metadata, + google: { + ...metadata.google, + hasRefreshToken, + connectionStatus, + syncStatus: metadata.google?.syncStatus ?? "none", + }, + }; + } + const google = await this.assessGoogleMetadata(userId, metadata); return { From 5d674fa8d5ea25831a856fda2943be0a6d169789 Mon Sep 17 00:00:00 2001 From: Cursor Agent Date: Sat, 7 Mar 2026 10:06:28 +0000 Subject: [PATCH 04/24] fix(sync,web): recover invalid sync tokens and handle connected status - fix(sync): force-restart sync when Google returns 410 error instead of delegating to assessGoogleMetadata, which checks token existence not validity - fix(web): add catch-all for connected users with unhandled sync status to show correct label Co-authored-by: Tyler Dane --- .../backend/src/sync/controllers/sync.controller.ts | 13 ++++++++++++- .../web/src/auth/hooks/oauth/useConnectGoogle.ts | 10 ++++++++++ 2 files changed, 22 insertions(+), 1 deletion(-) diff --git a/packages/backend/src/sync/controllers/sync.controller.ts b/packages/backend/src/sync/controllers/sync.controller.ts index 4dd7636a8..70630a665 100644 --- a/packages/backend/src/sync/controllers/sync.controller.ts +++ b/packages/backend/src/sync/controllers/sync.controller.ts @@ -113,7 +113,18 @@ export class SyncController { resourceId, }); - await userMetadataService.assessGoogleMetadata(userId); + // Force-restart sync to recover from invalid sync token. + // When Google returns 410 (sync token invalid), the token may still exist + // in the database but is no longer valid. assessGoogleMetadata checks token + // existence, not validity, so we must force-restart directly. + userService + .restartGoogleCalendarSync(userId, { force: true }) + .catch((err) => { + logger.error( + `Something went wrong with recovering google calendars for user: ${userId}`, + err, + ); + }); res.status(Status.NO_CONTENT).send(); }; diff --git a/packages/web/src/auth/hooks/oauth/useConnectGoogle.ts b/packages/web/src/auth/hooks/oauth/useConnectGoogle.ts index af90f1b5a..076347d25 100644 --- a/packages/web/src/auth/hooks/oauth/useConnectGoogle.ts +++ b/packages/web/src/auth/hooks/oauth/useConnectGoogle.ts @@ -44,6 +44,16 @@ export const useConnectGoogle = () => { }; } + if (connectionStatus === "connected") { + // Catch-all for connected users with unhandled sync status + return { + label: "Google Calendar Connected", + icon: "CheckCircleIcon", + isDisabled: false, + onSelect: undefined, + }; + } + if (connectionStatus === "reconnect_required") { return { label: "Reconnect Google Calendar", From f7034dbda41027f2372b34d0c865960063fca722 Mon Sep 17 00:00:00 2001 From: Cursor Agent Date: Sat, 7 Mar 2026 10:17:43 +0000 Subject: [PATCH 05/24] fix(web): remove redundant syncStatus check in useConnectGoogle hook Co-authored-by: Tyler Dane --- packages/web/src/auth/hooks/oauth/useConnectGoogle.ts | 9 --------- 1 file changed, 9 deletions(-) diff --git a/packages/web/src/auth/hooks/oauth/useConnectGoogle.ts b/packages/web/src/auth/hooks/oauth/useConnectGoogle.ts index 076347d25..23d88687b 100644 --- a/packages/web/src/auth/hooks/oauth/useConnectGoogle.ts +++ b/packages/web/src/auth/hooks/oauth/useConnectGoogle.ts @@ -35,15 +35,6 @@ export const useConnectGoogle = () => { }; } - if (connectionStatus === "connected" && syncStatus === "healthy") { - return { - label: "Google Calendar Connected", - icon: "CheckCircleIcon", - isDisabled: false, - onSelect: undefined, - }; - } - if (connectionStatus === "connected") { // Catch-all for connected users with unhandled sync status return { From 2942646b6df200cf6ff53136c913387d6e2d68a2 Mon Sep 17 00:00:00 2001 From: Tyler Dane Date: Sat, 7 Mar 2026 12:24:35 -0800 Subject: [PATCH 06/24] feat(sync): enhance gcal import handling in SyncController tests - Introduced new functions to wait for Google Calendar import start and end events. - Updated tests to utilize these functions for improved clarity and reliability. - Refactored import result parsing for better type safety and readability. - Adjusted test assertions to ensure correct behavior during import processes. --- .../sync/controllers/sync.controller.test.ts | 98 +++++++++++++++---- 1 file changed, 81 insertions(+), 17 deletions(-) diff --git a/packages/backend/src/sync/controllers/sync.controller.test.ts b/packages/backend/src/sync/controllers/sync.controller.test.ts index 45d8d59c2..4841c5430 100644 --- a/packages/backend/src/sync/controllers/sync.controller.test.ts +++ b/packages/backend/src/sync/controllers/sync.controller.test.ts @@ -6,6 +6,8 @@ import { faker } from "@faker-js/faker"; import { EVENT_CHANGED, GOOGLE_REVOKED, + IMPORT_GCAL_END, + IMPORT_GCAL_START, } from "@core/constants/websocket.constants"; import { Status } from "@core/errors/status.codes"; import { Resource_Sync, XGoogleResourceState } from "@core/types/sync.types"; @@ -28,7 +30,6 @@ import { } from "@backend/__tests__/helpers/mock.db.setup"; import { invalidGrant400Error } from "@backend/__tests__/mocks.gcal/errors/error.google.invalidGrant"; import { missingRefreshTokenError } from "@backend/__tests__/mocks.gcal/errors/error.missingRefreshToken"; -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"; @@ -41,6 +42,67 @@ import userService from "@backend/user/services/user.service"; describe("SyncController", () => { const baseDriver = new BaseDriver(); const syncDriver = new SyncControllerDriver(baseDriver); + const importTimeoutMs = 7_000; + + /** Shape of the JSON string emitted on successful gcal import (IMPORT_GCAL_END). */ + interface ImportSummary { + eventsCount: number; + calendarsCount: number; + } + + function parseImportResult(result: string): ImportSummary { + return JSON.parse(result) as ImportSummary; + } + + async function waitUntilImportGCalStart( + websocketClient: Socket, + beforeEvent: () => Promise = () => Promise.resolve(), + afterEvent: (...args: void[]) => Promise = (...args) => + Promise.resolve(args as Result), + ): Promise { + return waitUntilEvent( + websocketClient, + IMPORT_GCAL_START, + importTimeoutMs, + beforeEvent, + afterEvent, + ); + } + + async function waitUntilImportGCalEnd( + websocketClient: Socket, + beforeEvent: () => Promise = () => Promise.resolve(), + afterEvent: (...args: [string | undefined]) => Promise = ( + ...args + ) => Promise.resolve(args as Result), + ): Promise { + return waitUntilEvent( + websocketClient, + IMPORT_GCAL_END, + importTimeoutMs, + beforeEvent, + afterEvent, + ); + } + + async function waitUntilUserWebsocketEvent< + Payload extends unknown[], + Result = Payload, + >( + websocketClient: Socket, + event: string, + beforeEvent: () => Promise = () => Promise.resolve(), + afterEvent: (...args: Payload) => Promise = (...args) => + Promise.resolve(args as unknown as Result), + ): Promise { + return waitUntilEvent( + websocketClient, + event, + importTimeoutMs, + beforeEvent, + afterEvent, + ); + } async function websocketUserFlow(waitForEventChanged = false): Promise<{ user: WithId; @@ -58,20 +120,20 @@ describe("SyncController", () => { ); const [importEnd, eventChanged] = await Promise.allSettled([ - syncDriver.waitUntilImportGCalEnd( + waitUntilImportGCalEnd( websocketClient, () => syncDriver.importGCal({ userId: user._id.toString() }), (reason) => Promise.resolve(reason), ), ...(waitForEventChanged - ? [baseDriver.waitUntilWebsocketEvent(websocketClient, EVENT_CHANGED)] + ? [waitUntilUserWebsocketEvent(websocketClient, EVENT_CHANGED)] : []), ]); expect(importEnd.status).toEqual("fulfilled"); const importResult = (importEnd as { value: unknown })?.value as string; // On success, the result is a JSON string with import summary (e.g., '{"eventsCount":5,"calendarsCount":1}') - const parsed = JSON.parse(importResult); + const parsed = parseImportResult(importResult); expect(parsed).toHaveProperty("eventsCount"); expect(parsed).toHaveProperty("calendarsCount"); @@ -169,7 +231,7 @@ describe("SyncController", () => { restartSpy.mockRestore(); }); - it("should not schedule duplicate restart when import is already running", async () => { + it("should delegate repeated missing-sync-token recovery to the restart service", async () => { const { user } = await UtilDriver.setupTestUser(); const userId = user._id.toString(); const restartSpy = jest @@ -215,7 +277,9 @@ describe("SyncController", () => { Status.NO_CONTENT, ); - expect(restartSpy).not.toHaveBeenCalled(); + expect(restartSpy).toHaveBeenCalledTimes(2); + expect(restartSpy).toHaveBeenNthCalledWith(1, userId, { force: true }); + expect(restartSpy).toHaveBeenNthCalledWith(2, userId, { force: true }); restartSpy.mockRestore(); }); @@ -500,7 +564,7 @@ describe("SyncController", () => { data: { sync: { importGCal: "restart" } }, }); - await syncDriver.waitUntilImportGCalEnd(websocketClient, () => + await waitUntilImportGCalEnd(websocketClient, () => syncDriver.importGCal({ userId }), ); @@ -528,7 +592,7 @@ describe("SyncController", () => { const getAllEventsSpy = jest.spyOn(gcalService, "getAllEvents"); - const failReason = await syncDriver.waitUntilImportGCalEnd( + const failReason = await waitUntilImportGCalEnd( websocketClient, () => syncDriver.importGCal({ userId }), (reason) => Promise.resolve(reason), @@ -573,7 +637,7 @@ describe("SyncController", () => { data: { sync: { importGCal: "importing" } }, }); - const failReason = await syncDriver.waitUntilImportGCalEnd( + const failReason = await waitUntilImportGCalEnd( websocketClient, () => syncDriver.importGCal({ userId }), (reason) => Promise.resolve(reason), @@ -618,14 +682,14 @@ describe("SyncController", () => { data: { sync: { importGCal: "restart" } }, }); - const result = await syncDriver.waitUntilImportGCalEnd( + const result = await waitUntilImportGCalEnd( websocketClient, () => syncDriver.importGCal({ userId }), (reason) => Promise.resolve(reason), ); // On success, result is a JSON string with import summary - const parsed = JSON.parse(result as string); + const parsed = parseImportResult(result as string); expect(parsed).toHaveProperty("eventsCount"); expect(parsed).toHaveProperty("calendarsCount"); @@ -666,14 +730,14 @@ describe("SyncController", () => { data: { sync: { importGCal: "errored" } }, }); - const result = await syncDriver.waitUntilImportGCalEnd( + const result = await waitUntilImportGCalEnd( websocketClient, () => syncDriver.importGCal({ userId }), (reason) => Promise.resolve(reason), ); // On success, result is a JSON string with import summary - const parsed = JSON.parse(result as string); + const parsed = parseImportResult(result as string); expect(parsed).toHaveProperty("eventsCount"); expect(parsed).toHaveProperty("calendarsCount"); @@ -707,7 +771,7 @@ describe("SyncController", () => { ); await expect( - syncDriver.waitUntilImportGCalStart( + waitUntilImportGCalStart( websocketClient, () => syncDriver.importGCal({ userId }), () => Promise.resolve(true), @@ -734,13 +798,13 @@ describe("SyncController", () => { Promise.resolve(websocketClient.connect()), ); - const result = await syncDriver.waitUntilImportGCalEnd( + const result = await waitUntilImportGCalEnd( websocketClient, () => syncDriver.importGCal({ userId }), (reason) => Promise.resolve(reason), ); // On success, result is a JSON string with import summary - const parsed = JSON.parse(result as string); + const parsed = parseImportResult(result as string); expect(parsed).toHaveProperty("eventsCount"); expect(parsed).toHaveProperty("calendarsCount"); @@ -765,7 +829,7 @@ describe("SyncController", () => { ); await expect( - baseDriver.waitUntilWebsocketEvent( + waitUntilUserWebsocketEvent( websocketClient, EVENT_CHANGED, () => syncDriver.importGCal({ userId }), From dc1443105e54604c02a37932d3b0b41acafcf0f1 Mon Sep 17 00:00:00 2001 From: Tyler Dane Date: Sat, 7 Mar 2026 16:00:52 -0800 Subject: [PATCH 07/24] fix(util): improve error handling in waitUntilEvent utility - Added a catch block to handle errors from the afterEvent promise, ensuring proper rejection with a standardized Error object. - Enhanced error handling in the beforeEvent callback to ensure consistent error reporting. --- packages/core/src/util/wait-until-event.util.ts | 8 +++++--- 1 file changed, 5 insertions(+), 3 deletions(-) diff --git a/packages/core/src/util/wait-until-event.util.ts b/packages/core/src/util/wait-until-event.util.ts index 98d580583..74fa13016 100644 --- a/packages/core/src/util/wait-until-event.util.ts +++ b/packages/core/src/util/wait-until-event.util.ts @@ -42,15 +42,17 @@ export async function waitUntilEvent< }, timeoutMs); const listener = (...payload: Payload) => { - afterEvent(...payload).then(resolve); + afterEvent(...payload) + .then(resolve) + .catch(reject); clearTimeout(timeout); }; eventEmitter.once(event as string, listener); - beforeEvent?.().catch((error) => { + beforeEvent?.().catch((error: unknown) => { eventEmitter.removeListener?.(event as string, listener); - reject(error); + reject(error instanceof Error ? error : new Error(String(error))); }); }); } From 9f9fa3475f211c406056267f84fd522e2b516dc0 Mon Sep 17 00:00:00 2001 From: Tyler Dane Date: Sat, 7 Mar 2026 16:24:47 -0800 Subject: [PATCH 08/24] feat(auth): enhance Google Calendar connection handling in useConnectGoogle hook - Refactored the useConnectGoogle hook to return a structured configuration for command actions and sidebar status. - Updated tests to reflect changes in the hook's return structure, ensuring accurate assertions for various connection states. - Improved the SidebarIconRow component to utilize the new configuration for displaying Google Calendar connection status. - Added a disabled state for the TooltipWrapper when the Google Calendar is connected or syncing. --- .../auth/hooks/oauth/useConnectGoogle.test.ts | 34 ++-- .../src/auth/hooks/oauth/useConnectGoogle.ts | 186 ++++++++++++++---- .../Tooltip/TooltipWrapper.test.tsx | 13 ++ .../src/components/Tooltip/TooltipWrapper.tsx | 9 +- .../SidebarIconRow/SidebarIconRow.test.tsx | 166 ++++++++++++++++ .../Sidebar/SidebarIconRow/SidebarIconRow.tsx | 69 +++++++ .../src/views/CmdPalette/CmdPalette.test.tsx | 137 +++++++++++++ .../web/src/views/CmdPalette/CmdPalette.tsx | 10 +- .../Day/components/DayCmdPalette.test.tsx | 28 +-- .../views/Day/components/DayCmdPalette.tsx | 10 +- .../Now/components/NowCmdPalette.test.tsx | 30 +-- .../views/Now/components/NowCmdPalette.tsx | 10 +- 12 files changed, 603 insertions(+), 99 deletions(-) create mode 100644 packages/web/src/views/Calendar/components/Sidebar/SidebarIconRow/SidebarIconRow.test.tsx create mode 100644 packages/web/src/views/CmdPalette/CmdPalette.test.tsx diff --git a/packages/web/src/auth/hooks/oauth/useConnectGoogle.test.ts b/packages/web/src/auth/hooks/oauth/useConnectGoogle.test.ts index 052ee23c3..555508ee1 100644 --- a/packages/web/src/auth/hooks/oauth/useConnectGoogle.test.ts +++ b/packages/web/src/auth/hooks/oauth/useConnectGoogle.test.ts @@ -33,8 +33,12 @@ describe("useConnectGoogle", () => { it("returns connect state when metadata is missing", () => { const { result } = renderHook(() => useConnectGoogle()); - expect(result.current.label).toBe("Connect Google Calendar"); - expect(result.current.isDisabled).toBe(false); + expect(result.current.commandAction.label).toBe("Connect Google Calendar"); + expect(result.current.commandAction.isDisabled).toBe(false); + expect(result.current.sidebarStatus.icon).toBe("CloudArrowUpIcon"); + expect(result.current.sidebarStatus.tooltip).toBe( + "Google Calendar not connected. Click to connect.", + ); }); it("returns connected state when metadata is healthy", () => { @@ -45,8 +49,11 @@ describe("useConnectGoogle", () => { const { result } = renderHook(() => useConnectGoogle()); - expect(result.current.label).toBe("Google Calendar Connected"); - expect(result.current.onSelect).toBeUndefined(); + expect(result.current.commandAction.label).toBe("Connect Google Calendar"); + expect(result.current.commandAction.isDisabled).toBe(true); + expect(result.current.commandAction.onSelect).toBeUndefined(); + expect(result.current.sidebarStatus.icon).toBe("CheckCircleIcon"); + expect(result.current.sidebarStatus.isDisabled).toBe(true); }); it("returns reconnect state when refresh token is missing", () => { @@ -57,8 +64,9 @@ describe("useConnectGoogle", () => { const { result } = renderHook(() => useConnectGoogle()); - expect(result.current.label).toBe("Reconnect Google Calendar"); - result.current.onSelect?.(); + expect(result.current.commandAction.label).toBe("Connect Google Calendar"); + expect(result.current.sidebarStatus.icon).toBe("LinkBreakIcon"); + result.current.commandAction.onSelect?.(); expect(mockLogin).toHaveBeenCalled(); expect(mockDispatch).toHaveBeenCalledWith( @@ -74,9 +82,11 @@ describe("useConnectGoogle", () => { const { result } = renderHook(() => useConnectGoogle()); - expect(result.current.label).toBe("Syncing Google Calendar..."); - expect(result.current.isDisabled).toBe(true); - expect(result.current.onSelect).toBeUndefined(); + expect(result.current.commandAction.label).toBe("Connect Google Calendar"); + expect(result.current.commandAction.isDisabled).toBe(true); + expect(result.current.commandAction.onSelect).toBeUndefined(); + expect(result.current.sidebarStatus.icon).toBe("SpinnerIcon"); + expect(result.current.sidebarStatus.isDisabled).toBe(true); }); it("returns repair state when sync needs attention", () => { @@ -87,9 +97,11 @@ describe("useConnectGoogle", () => { const { result } = renderHook(() => useConnectGoogle()); - expect(result.current.label).toBe("Repair Google Calendar"); + expect(result.current.commandAction.label).toBe("Connect Google Calendar"); + expect(result.current.commandAction.isDisabled).toBe(false); + expect(result.current.sidebarStatus.icon).toBe("CloudWarningIcon"); - result.current.onSelect?.(); + result.current.sidebarStatus.onSelect?.(); expect(mockLogin).toHaveBeenCalled(); expect(mockDispatch).toHaveBeenCalledWith( diff --git a/packages/web/src/auth/hooks/oauth/useConnectGoogle.ts b/packages/web/src/auth/hooks/oauth/useConnectGoogle.ts index 23d88687b..d623db138 100644 --- a/packages/web/src/auth/hooks/oauth/useConnectGoogle.ts +++ b/packages/web/src/auth/hooks/oauth/useConnectGoogle.ts @@ -1,63 +1,165 @@ import { useCallback } from "react"; +import { + type GoogleConnectionStatus, + type GoogleSyncStatus, +} from "@core/types/user.types"; import { useGoogleAuth } from "@web/auth/hooks/oauth/useGoogleAuth"; import { selectGoogleMetadata } from "@web/ducks/auth/selectors/user-metadata.selectors"; import { settingsSlice } from "@web/ducks/settings/slices/settings.slice"; import { useAppDispatch, useAppSelector } from "@web/store/store.hooks"; -export const useConnectGoogle = () => { - const dispatch = useAppDispatch(); - const googleMetadata = useAppSelector(selectGoogleMetadata); - const { login } = useGoogleAuth(); +type GoogleUiState = + | "not_connected" + | "reconnect_required" + | "connected_healthy" + | "connected_repairing" + | "connected_attention"; - const onOpenGoogleAuth = useCallback(() => { - login(); - dispatch(settingsSlice.actions.closeCmdPalette()); - }, [dispatch, login]); +type SidebarStatusIcon = + | "CloudArrowUpIcon" + | "LinkBreakIcon" + | "CheckCircleIcon" + | "SpinnerIcon" + | "CloudWarningIcon"; - const connectionStatus = googleMetadata?.connectionStatus ?? "not_connected"; - const syncStatus = googleMetadata?.syncStatus ?? "none"; +type CommandActionIcon = "CloudArrowUpIcon"; + +type GoogleUiConfig = { + commandAction: { + label: string; + icon: CommandActionIcon; + isDisabled: boolean; + onSelect?: () => void; + }; + sidebarStatus: { + icon: SidebarStatusIcon; + tooltip: string; + isDisabled: boolean; + onSelect?: () => void; + }; +}; + +const COMMAND_LABEL = "Connect Google Calendar"; +const COMMAND_ICON: CommandActionIcon = "CloudArrowUpIcon"; + +const getGoogleUiState = ({ + connectionStatus, + syncStatus, +}: { + connectionStatus: GoogleConnectionStatus; + syncStatus: GoogleSyncStatus; +}): GoogleUiState => { + if (connectionStatus === "reconnect_required") { + return "reconnect_required"; + } if (connectionStatus === "connected" && syncStatus === "repairing") { - return { - label: "Syncing Google Calendar...", - icon: "ArrowPathIcon", - isDisabled: true, - onSelect: undefined, - }; + return "connected_repairing"; } if (connectionStatus === "connected" && syncStatus === "attention") { - return { - label: "Repair Google Calendar", - icon: "ArrowPathIcon", - isDisabled: false, - onSelect: onOpenGoogleAuth, - }; + return "connected_attention"; } if (connectionStatus === "connected") { - // Catch-all for connected users with unhandled sync status - return { - label: "Google Calendar Connected", - icon: "CheckCircleIcon", - isDisabled: false, - onSelect: undefined, - }; + return "connected_healthy"; } - if (connectionStatus === "reconnect_required") { - return { - label: "Reconnect Google Calendar", - icon: "ArrowPathIcon", - isDisabled: false, - onSelect: onOpenGoogleAuth, - }; + return "not_connected"; +}; + +const getGoogleUiConfig = ( + state: GoogleUiState, + onOpenGoogleAuth: () => void, +): GoogleUiConfig => { + switch (state) { + case "not_connected": + return { + commandAction: { + label: COMMAND_LABEL, + icon: COMMAND_ICON, + isDisabled: false, + onSelect: onOpenGoogleAuth, + }, + sidebarStatus: { + icon: "CloudArrowUpIcon", + tooltip: "Google Calendar not connected. Click to connect.", + isDisabled: false, + onSelect: onOpenGoogleAuth, + }, + }; + case "reconnect_required": + return { + commandAction: { + label: COMMAND_LABEL, + icon: COMMAND_ICON, + isDisabled: false, + onSelect: onOpenGoogleAuth, + }, + sidebarStatus: { + icon: "LinkBreakIcon", + tooltip: "Google Calendar needs reconnecting. Click to reconnect.", + isDisabled: false, + onSelect: onOpenGoogleAuth, + }, + }; + case "connected_repairing": + return { + commandAction: { + label: COMMAND_LABEL, + icon: COMMAND_ICON, + isDisabled: true, + }, + sidebarStatus: { + icon: "SpinnerIcon", + tooltip: "Google Calendar is syncing in the background.", + isDisabled: true, + }, + }; + case "connected_attention": + return { + commandAction: { + label: COMMAND_LABEL, + icon: COMMAND_ICON, + isDisabled: false, + onSelect: onOpenGoogleAuth, + }, + sidebarStatus: { + icon: "CloudWarningIcon", + tooltip: "Google Calendar needs repair. Click to reconnect.", + isDisabled: false, + onSelect: onOpenGoogleAuth, + }, + }; + case "connected_healthy": + return { + commandAction: { + label: COMMAND_LABEL, + icon: COMMAND_ICON, + isDisabled: true, + }, + sidebarStatus: { + icon: "CheckCircleIcon", + tooltip: "Google Calendar connected.", + isDisabled: true, + }, + }; } +}; - return { - label: "Connect Google Calendar", - icon: "CloudArrowUpIcon", - isDisabled: false, - onSelect: onOpenGoogleAuth, - }; +export const useConnectGoogle = () => { + const dispatch = useAppDispatch(); + const googleMetadata = useAppSelector(selectGoogleMetadata); + const { login } = useGoogleAuth(); + + const onOpenGoogleAuth = useCallback(() => { + login(); + dispatch(settingsSlice.actions.closeCmdPalette()); + }, [dispatch, login]); + + const connectionStatus = googleMetadata?.connectionStatus ?? "not_connected"; + const syncStatus = googleMetadata?.syncStatus ?? "none"; + const state = getGoogleUiState({ connectionStatus, syncStatus }); + + return getGoogleUiConfig(state, onOpenGoogleAuth); }; diff --git a/packages/web/src/components/Tooltip/TooltipWrapper.test.tsx b/packages/web/src/components/Tooltip/TooltipWrapper.test.tsx index 0c44f56bc..087c263c2 100644 --- a/packages/web/src/components/Tooltip/TooltipWrapper.test.tsx +++ b/packages/web/src/components/Tooltip/TooltipWrapper.test.tsx @@ -69,6 +69,19 @@ describe("TooltipWrapper", () => { expect(onClick).toHaveBeenCalledTimes(1); }); + it("does not call onClick when disabled", () => { + const onClick = jest.fn(); + render( + + + , + ); + + fireEvent.click(screen.getByRole("button", { name: /disabled/i })); + + expect(onClick).not.toHaveBeenCalled(); + }); + it("does not render tooltip content until opened", async () => { const user = userEvent.setup(); render( diff --git a/packages/web/src/components/Tooltip/TooltipWrapper.tsx b/packages/web/src/components/Tooltip/TooltipWrapper.tsx index e77ac4779..eb728592b 100644 --- a/packages/web/src/components/Tooltip/TooltipWrapper.tsx +++ b/packages/web/src/components/Tooltip/TooltipWrapper.tsx @@ -15,6 +15,7 @@ import { TooltipDescription } from "./Description/TooltipDescription"; export interface Props { children: ReactNode; description?: string; + disabled?: boolean; onClick?: () => void; shortcut?: string | ReactNode; } @@ -22,6 +23,7 @@ export interface Props { export const TooltipWrapper: React.FC = ({ children, description, + disabled = false, onClick = () => {}, shortcut, }) => { @@ -29,7 +31,12 @@ export const TooltipWrapper: React.FC = ({ return ( - {children} + + {children} + ({ + useGoogleAuth: () => ({ + login: mockLogin, + }), +})); + +jest.mock("@web/common/hooks/useVersionCheck", () => ({ + useVersionCheck: () => ({ + isUpdateAvailable: false, + }), +})); + +jest.mock("@web/components/Tooltip/TooltipWrapper", () => ({ + TooltipWrapper: ({ + children, + description, + disabled, + onClick, + }: { + children: ReactNode; + description?: string; + disabled?: boolean; + onClick?: () => void; + }) => ( + + ), +})); + +describe("SidebarIconRow", () => { + beforeEach(() => { + jest.clearAllMocks(); + }); + + it("shows the connect icon action when Google Calendar is not connected", () => { + render(, { + state: { + userMetadata: { + current: { + google: { + connectionStatus: "not_connected", + syncStatus: "none", + }, + }, + }, + }, + }); + + expect( + screen.getByRole("button", { + name: "Google Calendar not connected. Click to connect.", + }), + ).toBeEnabled(); + expect( + screen.getByLabelText("Google Calendar not connected"), + ).toBeInTheDocument(); + }); + + it("shows the reconnect icon action when reconnect is required", () => { + render(, { + state: { + userMetadata: { + current: { + google: { + connectionStatus: "reconnect_required", + syncStatus: "none", + }, + }, + }, + }, + }); + + expect( + screen.getByRole("button", { + name: "Google Calendar needs reconnecting. Click to reconnect.", + }), + ).toBeEnabled(); + expect( + screen.getByLabelText("Google Calendar needs reconnecting"), + ).toBeInTheDocument(); + }); + + it("disables the sidebar action when Google Calendar is healthy", () => { + render(, { + state: { + userMetadata: { + current: { + google: { + connectionStatus: "connected", + syncStatus: "healthy", + }, + }, + }, + }, + }); + + expect( + screen.getByRole("button", { + name: "Google Calendar connected.", + }), + ).toBeDisabled(); + expect( + screen.getByLabelText("Google Calendar connected"), + ).toBeInTheDocument(); + }); + + it("disables the sidebar action while Google Calendar is repairing", () => { + render(, { + state: { + userMetadata: { + current: { + google: { + connectionStatus: "connected", + syncStatus: "repairing", + }, + }, + }, + }, + }); + + expect( + screen.getByRole("button", { + name: "Google Calendar is syncing in the background.", + }), + ).toBeDisabled(); + expect( + screen.getByLabelText("Google Calendar syncing"), + ).toBeInTheDocument(); + }); + + it("clicks through to repair when Google Calendar needs attention", () => { + render(, { + state: { + userMetadata: { + current: { + google: { + connectionStatus: "connected", + syncStatus: "attention", + }, + }, + }, + }, + }); + + fireEvent.click( + screen.getByRole("button", { + name: "Google Calendar needs repair. Click to reconnect.", + }), + ); + + expect(mockLogin).toHaveBeenCalled(); + }); +}); diff --git a/packages/web/src/views/Calendar/components/Sidebar/SidebarIconRow/SidebarIconRow.tsx b/packages/web/src/views/Calendar/components/Sidebar/SidebarIconRow/SidebarIconRow.tsx index cf1f681df..2376c16b9 100644 --- a/packages/web/src/views/Calendar/components/Sidebar/SidebarIconRow/SidebarIconRow.tsx +++ b/packages/web/src/views/Calendar/components/Sidebar/SidebarIconRow/SidebarIconRow.tsx @@ -1,3 +1,10 @@ +import { + CheckCircleIcon, + CloudArrowUpIcon, + CloudWarningIcon, + LinkBreakIcon, +} from "@phosphor-icons/react"; +import { useConnectGoogle } from "@web/auth/hooks/oauth/useConnectGoogle"; import { useVersionCheck } from "@web/common/hooks/useVersionCheck"; import { theme } from "@web/common/styles/theme"; import { getModifierKeyIcon } from "@web/common/utils/shortcut/shortcut.util"; @@ -19,12 +26,67 @@ import { LeftIconGroup, } from "@web/views/Calendar/components/Sidebar/styled"; +const getGoogleStatusIcon = ({ + icon, +}: { + icon: + | "CloudArrowUpIcon" + | "LinkBreakIcon" + | "CheckCircleIcon" + | "SpinnerIcon" + | "CloudWarningIcon"; +}) => { + switch (icon) { + case "LinkBreakIcon": + return ( + + ); + case "CheckCircleIcon": + return ( + + ); + case "SpinnerIcon": + return ( + + ); + case "CloudWarningIcon": + return ( + + ); + case "CloudArrowUpIcon": + return ( + + ); + } +}; + export const SidebarIconRow = () => { const dispatch = useAppDispatch(); const tab = useAppSelector(selectSidebarTab); const gCalImport = useAppSelector(selectImportGCalState); const isCmdPaletteOpen = useAppSelector(selectIsCmdPaletteOpen); const { isUpdateAvailable } = useVersionCheck(); + const { sidebarStatus } = useConnectGoogle(); const handleUpdateReload = () => { window.location.reload(); @@ -90,6 +152,13 @@ export const SidebarIconRow = () => { } /> + + {getGoogleStatusIcon({ icon: sidebarStatus.icon })} + {gCalImport.importing ? ( diff --git a/packages/web/src/views/CmdPalette/CmdPalette.test.tsx b/packages/web/src/views/CmdPalette/CmdPalette.test.tsx new file mode 100644 index 000000000..d0e2a5f88 --- /dev/null +++ b/packages/web/src/views/CmdPalette/CmdPalette.test.tsx @@ -0,0 +1,137 @@ +import { act } from "react"; +import { fireEvent, screen, waitFor } from "@testing-library/react"; +import dayjs from "@core/util/date/dayjs"; +import { render } from "@web/__tests__/__mocks__/mock.render"; +import CmdPalette from "@web/views/CmdPalette/CmdPalette"; + +jest.mock("react-cmdk", () => { + const React = require("react"); + + const CommandPalette = ({ + children, + isOpen, + onChangeSearch, + placeholder, + search, + }: any) => { + if (!isOpen) { + return null; + } + + return ( +
+ onChangeSearch(event.target.value)} + placeholder={placeholder} + value={search} + /> + {children} +
+ ); + }; + + CommandPalette.Page = ({ children }: any) =>
{children}
; + CommandPalette.List = ({ children, heading }: any) => ( +
+

{heading}

+ {children} +
+ ); + CommandPalette.ListItem = ({ children, disabled, onClick }: any) => ( + + ); + CommandPalette.FreeSearchAction = () =>
No results
; + + return { + __esModule: true, + default: CommandPalette, + filterItems: (items: unknown) => items, + getItemIndex: () => 0, + }; +}); + +jest.mock("@web/common/utils/dom/event-target-visibility.util", () => ({ + onEventTargetVisibility: (cb: () => void) => () => cb(), +})); + +const mockLogin = jest.fn(); +jest.mock("@web/auth/hooks/oauth/useGoogleAuth", () => ({ + useGoogleAuth: () => ({ + login: mockLogin, + }), +})); + +const baseProps = { + today: dayjs(), + isCurrentWeek: true, + startOfView: dayjs(), + endOfView: dayjs(), + scrollUtil: { + scrollToNow: jest.fn(), + }, + util: { + goToToday: jest.fn(), + }, +} as const; + +describe("CmdPalette", () => { + beforeEach(() => { + jest.clearAllMocks(); + }); + + it("shows the generic Google action when metadata is missing", () => { + render(, { + state: { settings: { isCmdPaletteOpen: true } }, + }); + + expect( + screen.getByRole("button", { name: /connect google calendar/i }), + ).toBeEnabled(); + }); + + it("disables the generic Google action when Google Calendar is connected", () => { + render(, { + state: { + settings: { isCmdPaletteOpen: true }, + userMetadata: { + current: { + google: { + connectionStatus: "connected", + syncStatus: "healthy", + }, + }, + }, + }, + }); + + expect( + screen.getByRole("button", { name: /connect google calendar/i }), + ).toBeDisabled(); + }); + + it("still triggers auth when Google Calendar needs repair", async () => { + render(, { + state: { + settings: { isCmdPaletteOpen: true }, + userMetadata: { + current: { + google: { + connectionStatus: "connected", + syncStatus: "attention", + }, + }, + }, + }, + }); + + act(() => + fireEvent.click( + screen.getByRole("button", { name: /connect google calendar/i }), + ), + ); + + await waitFor(() => expect(mockLogin).toHaveBeenCalled()); + }); +}); diff --git a/packages/web/src/views/CmdPalette/CmdPalette.tsx b/packages/web/src/views/CmdPalette/CmdPalette.tsx index 54c1bb89b..2082f3b2c 100644 --- a/packages/web/src/views/CmdPalette/CmdPalette.tsx +++ b/packages/web/src/views/CmdPalette/CmdPalette.tsx @@ -41,7 +41,7 @@ const CmdPalette = ({ const open = useAppSelector(selectIsCmdPaletteOpen); const [page] = useState<"root" | "projects">("root"); const [search, setSearch] = useState(""); - const { label, icon, isDisabled, onSelect } = useConnectGoogle(); + const { commandAction } = useConnectGoogle(); const authCmdItems = useAuthCmdItems(); const handleCreateSomedayDraft = async ( @@ -137,10 +137,10 @@ const CmdPalette = ({ items: [ { id: "connect-google-calendar", - children: label, - icon, - disabled: isDisabled, - onClick: onSelect, + children: commandAction.label, + icon: commandAction.icon, + disabled: commandAction.isDisabled, + onClick: commandAction.onSelect, }, ...authCmdItems, { diff --git a/packages/web/src/views/Day/components/DayCmdPalette.test.tsx b/packages/web/src/views/Day/components/DayCmdPalette.test.tsx index c50c69cea..d78367096 100644 --- a/packages/web/src/views/Day/components/DayCmdPalette.test.tsx +++ b/packages/web/src/views/Day/components/DayCmdPalette.test.tsx @@ -253,12 +253,9 @@ describe("DayCmdPalette", () => { ); expect(screen.getByText("Connect Google Calendar")).toBeInTheDocument(); - expect( - screen.queryByText("Google Calendar Connected"), - ).not.toBeInTheDocument(); }); - it("shows 'Google Calendar Connected' when metadata is healthy", async () => { + it("disables the generic action when Google Calendar is healthy", async () => { await act(() => render(, { state: { @@ -275,10 +272,9 @@ describe("DayCmdPalette", () => { }), ); - expect(screen.getByText("Google Calendar Connected")).toBeInTheDocument(); expect( - screen.queryByText("Connect Google Calendar"), - ).not.toBeInTheDocument(); + screen.getByText("Connect Google Calendar").closest("button"), + ).toHaveAttribute("aria-disabled", "true"); }); it("triggers login when reconnect is required", async () => { @@ -299,9 +295,7 @@ describe("DayCmdPalette", () => { }), ); - await act(() => - user.click(screen.getByText("Reconnect Google Calendar")), - ); + await act(() => user.click(screen.getByText("Connect Google Calendar"))); expect(mockLogin).toHaveBeenCalled(); expect(mockDispatch).toHaveBeenCalledWith( @@ -309,7 +303,7 @@ describe("DayCmdPalette", () => { ); }); - it("shows syncing state while repair is running", async () => { + it("disables the generic action while repair is running", async () => { await act(() => render(, { state: { @@ -326,14 +320,12 @@ describe("DayCmdPalette", () => { }), ); - const button = screen - .getByText("Syncing Google Calendar...") - .closest("button"); - - expect(button).toBeDisabled(); + expect( + screen.getByText("Connect Google Calendar").closest("button"), + ).toHaveAttribute("aria-disabled", "true"); }); - it("shows repair action when sync needs attention", async () => { + it("keeps the generic action enabled when sync needs attention", async () => { const user = userEvent.setup(); await act(() => render(, { @@ -351,7 +343,7 @@ describe("DayCmdPalette", () => { }), ); - await act(() => user.click(screen.getByText("Repair Google Calendar"))); + await act(() => user.click(screen.getByText("Connect Google Calendar"))); expect(mockLogin).toHaveBeenCalled(); }); diff --git a/packages/web/src/views/Day/components/DayCmdPalette.tsx b/packages/web/src/views/Day/components/DayCmdPalette.tsx index f74049215..64239762a 100644 --- a/packages/web/src/views/Day/components/DayCmdPalette.tsx +++ b/packages/web/src/views/Day/components/DayCmdPalette.tsx @@ -25,7 +25,7 @@ export const DayCmdPalette = ({ onGoToToday }: DayCmdPaletteProps) => { const [page] = useState<"root">("root"); const [search, setSearch] = useState(""); const today = dayjs(); - const { label, icon, isDisabled, onSelect } = useConnectGoogle(); + const { commandAction } = useConnectGoogle(); const authCmdItems = useAuthCmdItems(); const filteredItems = filterItems( @@ -74,10 +74,10 @@ export const DayCmdPalette = ({ onGoToToday }: DayCmdPaletteProps) => { items: [ { id: "connect-google-calendar", - children: label, - icon, - disabled: isDisabled, - onClick: onSelect, + children: commandAction.label, + icon: commandAction.icon, + disabled: commandAction.isDisabled, + onClick: commandAction.onSelect, }, ...authCmdItems, { diff --git a/packages/web/src/views/Now/components/NowCmdPalette.test.tsx b/packages/web/src/views/Now/components/NowCmdPalette.test.tsx index 359a0beed..42bccdac8 100644 --- a/packages/web/src/views/Now/components/NowCmdPalette.test.tsx +++ b/packages/web/src/views/Now/components/NowCmdPalette.test.tsx @@ -142,13 +142,12 @@ describe("NowCmdPalette", () => { it("shows 'Connect Google Calendar' when metadata is missing", () => { render(, { state: initialState }); - expect(screen.getByText("Connect Google Calendar")).toBeInTheDocument(); expect( - screen.queryByText("Google Calendar Connected"), - ).not.toBeInTheDocument(); + screen.getByRole("button", { name: "Connect Google Calendar" }), + ).toBeEnabled(); }); - it("shows 'Google Calendar Connected' when metadata is healthy", () => { + it("disables the generic action when Google Calendar is healthy", () => { render(, { state: { ...initialState, @@ -163,10 +162,9 @@ describe("NowCmdPalette", () => { }, }); - expect(screen.getByText("Google Calendar Connected")).toBeInTheDocument(); expect( - screen.queryByText("Connect Google Calendar"), - ).not.toBeInTheDocument(); + screen.getByRole("button", { name: "Connect Google Calendar" }), + ).toBeDisabled(); }); it("shows reconnect and triggers login when needed", async () => { @@ -184,11 +182,15 @@ describe("NowCmdPalette", () => { }, }); - act(() => fireEvent.click(screen.getByText("Reconnect Google Calendar"))); + act(() => + fireEvent.click( + screen.getByRole("button", { name: "Connect Google Calendar" }), + ), + ); await waitFor(() => expect(mockLogin).toHaveBeenCalled()); }); - it("shows syncing state and disables clicks while repairing", async () => { + it("disables the generic action while repairing", async () => { render(, { state: { ...initialState, @@ -204,12 +206,12 @@ describe("NowCmdPalette", () => { }); const button = screen.getByRole("button", { - name: "Syncing Google Calendar...", + name: "Connect Google Calendar", }); expect(button).toBeDisabled(); }); - it("shows repair action when sync needs attention", async () => { + it("keeps the generic action enabled when sync needs attention", async () => { render(, { state: { ...initialState, @@ -224,7 +226,11 @@ describe("NowCmdPalette", () => { }, }); - act(() => fireEvent.click(screen.getByText("Repair Google Calendar"))); + act(() => + fireEvent.click( + screen.getByRole("button", { name: "Connect Google Calendar" }), + ), + ); await waitFor(() => expect(mockLogin).toHaveBeenCalled()); }); diff --git a/packages/web/src/views/Now/components/NowCmdPalette.tsx b/packages/web/src/views/Now/components/NowCmdPalette.tsx index a33aaec28..db313331e 100644 --- a/packages/web/src/views/Now/components/NowCmdPalette.tsx +++ b/packages/web/src/views/Now/components/NowCmdPalette.tsx @@ -16,7 +16,7 @@ export const NowCmdPalette = () => { const open = useAppSelector(selectIsCmdPaletteOpen); const [page] = useState<"root">("root"); const [search, setSearch] = useState(""); - const { label, icon, isDisabled, onSelect } = useConnectGoogle(); + const { commandAction } = useConnectGoogle(); const authCmdItems = useAuthCmdItems(); const filteredItems = filterItems( @@ -51,10 +51,10 @@ export const NowCmdPalette = () => { items: [ { id: "connect-google-calendar", - children: label, - icon, - disabled: isDisabled, - onClick: onSelect, + children: commandAction.label, + icon: commandAction.icon, + disabled: commandAction.isDisabled, + onClick: commandAction.onSelect, }, ...authCmdItems, { From 04413ed526a775eaaca26a8ea01e2dac27fd17ce Mon Sep 17 00:00:00 2001 From: Tyler Dane Date: Sat, 7 Mar 2026 17:04:09 -0800 Subject: [PATCH 09/24] feat(sync): enhance Google Calendar import functionality and testing - Added a `force` option to the `importGCal` method in `SyncControllerDriver` to allow forced imports. - Introduced a new `WatchDriver` for managing watch collection tests, improving test isolation and flexibility. - Updated `importGCal` method in `SyncController` to handle the `force` parameter, enabling forced sync operations. - Enhanced tests in `sync.controller.test.ts` to verify forced import behavior and ensure correct handling of completed sync states. - Improved user metadata service to return healthy status without active watches when not using HTTPS. - Updated user service tests to validate event sync token persistence without HTTPS, ensuring local sync health. --- .../drivers/sync.controller.driver.ts | 2 + .../src/__tests__/drivers/watch.driver.ts | 14 ++++++ .../sync/controllers/sync.controller.test.ts | 29 +++++++++++ .../src/sync/controllers/sync.controller.ts | 12 ++++- .../backend/src/sync/services/sync.service.ts | 22 +++------ .../services/user-metadata.service.test.ts | 24 +++++++++ .../user/services/user-metadata.service.ts | 5 ++ .../src/user/services/user.service.test.ts | 49 ++++++++++++++++--- .../backend/src/user/services/user.service.ts | 5 +- 9 files changed, 137 insertions(+), 25 deletions(-) create mode 100644 packages/backend/src/__tests__/drivers/watch.driver.ts diff --git a/packages/backend/src/__tests__/drivers/sync.controller.driver.ts b/packages/backend/src/__tests__/drivers/sync.controller.driver.ts index 6dc0363cc..6aa12f93e 100644 --- a/packages/backend/src/__tests__/drivers/sync.controller.driver.ts +++ b/packages/backend/src/__tests__/drivers/sync.controller.driver.ts @@ -64,12 +64,14 @@ export class SyncControllerDriver { async importGCal( session?: { userId: string }, + body?: { force?: boolean }, status: Status = Status.NO_CONTENT, ): Promise< Omit & { body: { id: string; status: string } } > { return request(this.baseDriver.getServerUri()) .post("/api/sync/import-gcal") + .send(body) .use(this.baseDriver.setSessionPlugin(session)) .expect(status); } diff --git a/packages/backend/src/__tests__/drivers/watch.driver.ts b/packages/backend/src/__tests__/drivers/watch.driver.ts new file mode 100644 index 000000000..e3c6fc9d0 --- /dev/null +++ b/packages/backend/src/__tests__/drivers/watch.driver.ts @@ -0,0 +1,14 @@ +import mongoService from "@backend/common/services/mongo.service"; + +/** + * Test driver for the watch collection. + * Use this instead of importing mongoService in tests so persistence can be + * swapped (e.g. away from Mongo) without changing test code. + */ +export class WatchDriver { + static deleteManyByUser( + userId: string, + ): ReturnType { + return mongoService.watch.deleteMany({ user: userId }); + } +} diff --git a/packages/backend/src/sync/controllers/sync.controller.test.ts b/packages/backend/src/sync/controllers/sync.controller.test.ts index 4841c5430..25aefdf37 100644 --- a/packages/backend/src/sync/controllers/sync.controller.test.ts +++ b/packages/backend/src/sync/controllers/sync.controller.test.ts @@ -578,6 +578,35 @@ describe("SyncController", () => { }); describe("Import Status: ", () => { + it("should force a repair import even after a completed sync", async () => { + const { user, websocketClient } = await websocketUserFlow(true); + const userId = user._id.toString(); + + const getAllEventsSpy = jest.spyOn(gcalService, "getAllEvents"); + + const { sync } = await userMetadataService.fetchUserMetadata(userId); + + expect(sync?.importGCal).toEqual("completed"); + + const result = await waitUntilImportGCalEnd( + websocketClient, + () => syncDriver.importGCal({ userId }, { force: true }), + (reason) => Promise.resolve(reason), + ); + + const parsed = parseImportResult(result as string); + + expect(parsed).toHaveProperty("eventsCount"); + expect(parsed).toHaveProperty("calendarsCount"); + expect(getAllEventsSpy).toHaveBeenCalled(); + + await waitUntilEvent(websocketClient, "disconnect", 100, () => + Promise.resolve(websocketClient.disconnect()), + ); + + getAllEventsSpy.mockRestore(); + }); + it("should not retry import once it has completed", async () => { const { user, websocketClient } = await websocketUserFlow(true); const userId = user._id.toString(); diff --git a/packages/backend/src/sync/controllers/sync.controller.ts b/packages/backend/src/sync/controllers/sync.controller.ts index 70630a665..05460b1e7 100644 --- a/packages/backend/src/sync/controllers/sync.controller.ts +++ b/packages/backend/src/sync/controllers/sync.controller.ts @@ -267,10 +267,18 @@ export class SyncController { } }; - static importGCal = async (req: Request, res: Response) => { + static importGCal = (req: Request, res: Response): void => { const userId = req.session!.getUserId(); + const isForce = req.body?.force === true; - userService.restartGoogleCalendarSync(userId); + userService + .restartGoogleCalendarSync(userId, { force: isForce }) + .catch((err) => { + logger.error( + `Something went wrong starting Google Calendar import for user: ${userId}`, + err, + ); + }); res.status(Status.NO_CONTENT).send(); }; diff --git a/packages/backend/src/sync/services/sync.service.ts b/packages/backend/src/sync/services/sync.service.ts index d15e13f3f..92a99f880 100644 --- a/packages/backend/src/sync/services/sync.service.ts +++ b/packages/backend/src/sync/services/sync.service.ts @@ -273,7 +273,7 @@ class SyncService { try { const syncImport = await createSyncImport(gcal); - const eventImports = Promise.all( + const eventImports = await Promise.all( gCalendarIds.map(async (gCalId) => { const { nextSyncToken, ...result } = await syncImport.importAllEvents( userId, @@ -281,19 +281,13 @@ class SyncService { 2500, ); - if (isUsingHttps()) { - await updateSync( - Resource_Sync.EVENTS, - userId, - gCalId, - { nextSyncToken }, - session, - ); - } else { - logger.warn( - `Skipped updating sync token for user: ${userId} and gCalId: ${gCalId} because not using https`, - ); - } + await updateSync( + Resource_Sync.EVENTS, + userId, + gCalId, + { nextSyncToken }, + session, + ); return { gCalId, ...result }; }), diff --git a/packages/backend/src/user/services/user-metadata.service.test.ts b/packages/backend/src/user/services/user-metadata.service.test.ts index 15cbcbeb7..a09448582 100644 --- a/packages/backend/src/user/services/user-metadata.service.test.ts +++ b/packages/backend/src/user/services/user-metadata.service.test.ts @@ -1,14 +1,22 @@ import { UserMetadataServiceDriver } from "@backend/__tests__/drivers/user-metadata.service.driver"; import { UserDriver } from "@backend/__tests__/drivers/user.driver"; import { UtilDriver } from "@backend/__tests__/drivers/util.driver"; +import { WatchDriver } from "@backend/__tests__/drivers/watch.driver"; import { cleanupCollections, cleanupTestDb, setupTestDb, } from "@backend/__tests__/helpers/mock.db.setup"; import { initSupertokens } from "@backend/common/middleware/supertokens.middleware"; +import { isUsingHttps } from "@backend/sync/util/sync.util"; import userService from "@backend/user/services/user.service"; +// eslint-disable-next-line @typescript-eslint/no-unsafe-return -- mock factory spreads requireActual +jest.mock("@backend/sync/util/sync.util", () => ({ + ...jest.requireActual("@backend/sync/util/sync.util"), + isUsingHttps: jest.fn(), +})); + describe("UserMetadataService", () => { const driver = new UserMetadataServiceDriver(); @@ -91,6 +99,22 @@ describe("UserMetadataService", () => { }); }); + it("returns healthy without active watches when running without https", async () => { + const { user } = await UtilDriver.setupTestUser(); + const userId = user._id.toString(); + (isUsingHttps as jest.Mock).mockReturnValueOnce(false); + + await WatchDriver.deleteManyByUser(userId); + + const metadata = await driver.fetchUserMetadata(userId); + + expect(metadata.google).toMatchObject({ + hasRefreshToken: true, + connectionStatus: "connected", + syncStatus: "healthy", + }); + }); + it("schedules repair and returns repairing when connected sync state is broken", async () => { const user = await UserDriver.createUser(); const userId = user._id.toString(); diff --git a/packages/backend/src/user/services/user-metadata.service.ts b/packages/backend/src/user/services/user-metadata.service.ts index 0b27620e0..7dce7f464 100644 --- a/packages/backend/src/user/services/user-metadata.service.ts +++ b/packages/backend/src/user/services/user-metadata.service.ts @@ -13,6 +13,7 @@ import { import dayjs from "@core/util/date/dayjs"; import mongoService from "@backend/common/services/mongo.service"; import { getSync } from "@backend/sync/util/sync.queries"; +import { isUsingHttps } from "@backend/sync/util/sync.util"; import { findCompassUserBy } from "@backend/user/queries/user.queries"; const logger = Logger("app:user.metadata.service"); @@ -79,6 +80,10 @@ class UserMetadataService { return false; } + if (!isUsingHttps()) { + return true; + } + const activeWatchCalendarIds = new Set( (await mongoService.watch.find({ user: userId }).toArray()) .filter(({ expiration }) => dayjs(expiration).isAfter(dayjs())) diff --git a/packages/backend/src/user/services/user.service.test.ts b/packages/backend/src/user/services/user.service.test.ts index d6ef59a35..b8217c49a 100644 --- a/packages/backend/src/user/services/user.service.test.ts +++ b/packages/backend/src/user/services/user.service.test.ts @@ -17,8 +17,17 @@ import { initSupertokens } from "@backend/common/middleware/supertokens.middlewa 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 { isUsingHttps } from "@backend/sync/util/sync.util"; import userMetadataService from "@backend/user/services/user-metadata.service"; import userService from "@backend/user/services/user.service"; +import { type Summary_Delete } from "@backend/user/types/user.types"; + +jest.mock("@backend/sync/util/sync.util", () => { + const actual = jest.requireActual< + typeof import("@backend/sync/util/sync.util") + >("@backend/sync/util/sync.util"); + return { ...actual, isUsingHttps: jest.fn(actual.isUsingHttps) }; +}); describe("UserService", () => { beforeAll(initSupertokens); @@ -38,7 +47,7 @@ describe("UserService", () => { expect(storedUser).toEqual( expect.objectContaining({ - email: gUser.email, + email: gUser.email as string, google: expect.objectContaining({ gRefreshToken: refreshToken, }), @@ -90,15 +99,16 @@ describe("UserService", () => { await SyncDriver.createSync(storedUser!, true); await userService.startGoogleCalendarSync(userId); - const summary = await userService.deleteCompassDataForUser(userId, false); + const summary: Summary_Delete = + await userService.deleteCompassDataForUser(userId, false); expect(summary).toEqual( expect.objectContaining({ - priorities: expect.any(Number), - calendars: expect.any(Number), - events: expect.any(Number), - syncs: expect.any(Number), - eventWatches: expect.any(Number), + priorities: expect.any(Number) as number, + calendars: expect.any(Number) as number, + events: expect.any(Number) as number, + syncs: expect.any(Number) as number, + eventWatches: expect.any(Number) as number, user: 1, }), ); @@ -215,6 +225,27 @@ describe("UserService", () => { importFullSpy.mockRestore(); startWatchingSpy.mockRestore(); }); + + it("persists event sync tokens without https so local sync can settle healthy", async () => { + const user = await UserDriver.createUser(); + const userId = user._id.toString(); + (isUsingHttps as jest.Mock).mockReturnValue(false); + + await userService.startGoogleCalendarSync(userId); + + const syncRecord = await mongoService.sync.findOne({ user: userId }); + const metadata = await userMetadataService.fetchUserMetadata(userId); + + expect(syncRecord?.google?.events?.length ?? 0).toBeGreaterThan(0); + expect( + syncRecord?.google?.events?.every(({ nextSyncToken }) => + Boolean(nextSyncToken), + ), + ).toBe(true); + expect(metadata.google?.syncStatus).toBe("healthy"); + + (isUsingHttps as jest.Mock).mockRestore(); + }); }); describe("stopGoogleCalendarSync", () => { @@ -232,7 +263,9 @@ describe("UserService", () => { calendars.map((calendar) => CompassCalendarSchema.safeParse(calendar)), ).toEqual( expect.arrayContaining( - calendars.map(() => expect.objectContaining({ success: true })), + calendars.map((): unknown => + expect.objectContaining({ success: true }), + ), ), ); diff --git a/packages/backend/src/user/services/user.service.ts b/packages/backend/src/user/services/user.service.ts index 4c8a7b1d1..d3b457682 100644 --- a/packages/backend/src/user/services/user.service.ts +++ b/packages/backend/src/user/services/user.service.ts @@ -79,7 +79,10 @@ class UserService { return user as unknown as UserProfile; }; - deleteCompassDataForUser = async (userId: string, gcalAccess = true) => { + deleteCompassDataForUser = async ( + userId: string, + gcalAccess = true, + ): Promise => { const _id = zObjectId.parse(userId); const summary: Summary_Delete = {}; const session = await mongoService.startSession(); From d239a1f6f296a157995bee9a65eacb9a26f71284 Mon Sep 17 00:00:00 2001 From: Tyler Dane Date: Sat, 7 Mar 2026 17:04:29 -0800 Subject: [PATCH 10/24] feat(sync): improve Google Calendar repair functionality in useConnectGoogle hook - Enhanced the useConnectGoogle hook to manage Google Calendar repair state, integrating a new repair action that triggers forced imports. - Updated the SyncApi to accept an options parameter for the importGCal method, allowing for forced sync operations. - Refactored tests for useConnectGoogle and related components to validate the new repair logic and ensure accurate assertions for various connection states. - Improved user interface feedback during repair processes, including updated tooltips and sidebar statuses. - Ensured that socket emissions for user metadata fetching are correctly triggered during import events. --- .../auth/hooks/oauth/useConnectGoogle.test.ts | 140 ++++++++++++++++-- .../src/auth/hooks/oauth/useConnectGoogle.ts | 58 ++++++-- packages/web/src/common/apis/sync.api.ts | 4 +- .../web/src/socket/hooks/useGcalSync.test.ts | 11 ++ packages/web/src/socket/hooks/useGcalSync.ts | 2 + .../SidebarIconRow/SidebarIconRow.test.tsx | 11 +- .../src/views/CmdPalette/CmdPalette.test.tsx | 13 +- .../web/src/views/CmdPalette/CmdPalette.tsx | 28 ++-- .../Day/components/DayCmdPalette.test.tsx | 9 +- .../Now/components/NowCmdPalette.test.tsx | 13 +- 10 files changed, 242 insertions(+), 47 deletions(-) diff --git a/packages/web/src/auth/hooks/oauth/useConnectGoogle.test.ts b/packages/web/src/auth/hooks/oauth/useConnectGoogle.test.ts index 555508ee1..80d797e52 100644 --- a/packages/web/src/auth/hooks/oauth/useConnectGoogle.test.ts +++ b/packages/web/src/auth/hooks/oauth/useConnectGoogle.test.ts @@ -1,10 +1,15 @@ import { renderHook } from "@testing-library/react"; import { useConnectGoogle } from "@web/auth/hooks/oauth/useConnectGoogle"; import { useGoogleAuth } from "@web/auth/hooks/oauth/useGoogleAuth"; +import { SyncApi } from "@web/common/apis/sync.api"; +import { selectGoogleMetadata } from "@web/ducks/auth/selectors/user-metadata.selectors"; +import { selectImportGCalState } from "@web/ducks/events/selectors/sync.selector"; +import { importGCalSlice } from "@web/ducks/events/slices/sync.slice"; import { settingsSlice } from "@web/ducks/settings/slices/settings.slice"; import { useAppDispatch, useAppSelector } from "@web/store/store.hooks"; jest.mock("@web/auth/hooks/oauth/useGoogleAuth"); +jest.mock("@web/common/apis/sync.api"); jest.mock("@web/store/store.hooks"); const mockUseGoogleAuth = useGoogleAuth as jest.MockedFunction< @@ -16,6 +21,7 @@ const mockUseAppDispatch = useAppDispatch as jest.MockedFunction< const mockUseAppSelector = useAppSelector as jest.MockedFunction< typeof useAppSelector >; +const mockSyncApi = SyncApi as jest.Mocked; describe("useConnectGoogle", () => { const mockDispatch = jest.fn(); @@ -26,8 +32,24 @@ describe("useConnectGoogle", () => { mockUseAppDispatch.mockReturnValue(mockDispatch); mockUseGoogleAuth.mockReturnValue({ login: mockLogin, + data: null, + loading: false, + }); + mockSyncApi.importGCal.mockResolvedValue(undefined as never); + mockUseAppSelector.mockImplementation((selector) => { + if (selector === selectGoogleMetadata) { + return undefined; + } + + if (selector === selectImportGCalState) { + return { + importing: false, + isImportPending: false, + }; + } + + return undefined; }); - mockUseAppSelector.mockReturnValue(undefined); }); it("returns connect state when metadata is missing", () => { @@ -42,9 +64,22 @@ describe("useConnectGoogle", () => { }); it("returns connected state when metadata is healthy", () => { - mockUseAppSelector.mockReturnValue({ - connectionStatus: "connected", - syncStatus: "healthy", + mockUseAppSelector.mockImplementation((selector) => { + if (selector === selectGoogleMetadata) { + return { + connectionStatus: "connected", + syncStatus: "healthy", + }; + } + + if (selector === selectImportGCalState) { + return { + importing: false, + isImportPending: false, + }; + } + + return undefined; }); const { result } = renderHook(() => useConnectGoogle()); @@ -57,9 +92,22 @@ describe("useConnectGoogle", () => { }); it("returns reconnect state when refresh token is missing", () => { - mockUseAppSelector.mockReturnValue({ - connectionStatus: "reconnect_required", - syncStatus: "none", + mockUseAppSelector.mockImplementation((selector) => { + if (selector === selectGoogleMetadata) { + return { + connectionStatus: "reconnect_required", + syncStatus: "none", + }; + } + + if (selector === selectImportGCalState) { + return { + importing: false, + isImportPending: false, + }; + } + + return undefined; }); const { result } = renderHook(() => useConnectGoogle()); @@ -75,9 +123,22 @@ describe("useConnectGoogle", () => { }); it("returns syncing state while repair is running", () => { - mockUseAppSelector.mockReturnValue({ - connectionStatus: "connected", - syncStatus: "repairing", + mockUseAppSelector.mockImplementation((selector) => { + if (selector === selectGoogleMetadata) { + return { + connectionStatus: "connected", + syncStatus: "repairing", + }; + } + + if (selector === selectImportGCalState) { + return { + importing: false, + isImportPending: false, + }; + } + + return undefined; }); const { result } = renderHook(() => useConnectGoogle()); @@ -90,9 +151,22 @@ describe("useConnectGoogle", () => { }); it("returns repair state when sync needs attention", () => { - mockUseAppSelector.mockReturnValue({ - connectionStatus: "connected", - syncStatus: "attention", + mockUseAppSelector.mockImplementation((selector) => { + if (selector === selectGoogleMetadata) { + return { + connectionStatus: "connected", + syncStatus: "attention", + }; + } + + if (selector === selectImportGCalState) { + return { + importing: false, + isImportPending: false, + }; + } + + return undefined; }); const { result } = renderHook(() => useConnectGoogle()); @@ -100,12 +174,50 @@ describe("useConnectGoogle", () => { expect(result.current.commandAction.label).toBe("Connect Google Calendar"); expect(result.current.commandAction.isDisabled).toBe(false); expect(result.current.sidebarStatus.icon).toBe("CloudWarningIcon"); + expect(result.current.sidebarStatus.tooltip).toBe( + "Google Calendar needs repair. Click to repair.", + ); result.current.sidebarStatus.onSelect?.(); - expect(mockLogin).toHaveBeenCalled(); + expect(mockSyncApi.importGCal).toHaveBeenCalledWith({ force: true }); + expect(mockDispatch).toHaveBeenCalledWith( + importGCalSlice.actions.clearImportResults(undefined), + ); + expect(mockDispatch).toHaveBeenCalledWith( + importGCalSlice.actions.setIsImportPending(true), + ); + expect(mockDispatch).toHaveBeenCalledWith( + importGCalSlice.actions.importing(true), + ); expect(mockDispatch).toHaveBeenCalledWith( settingsSlice.actions.closeCmdPalette(), ); }); + + it("uses repairing state while local import is pending even before metadata catches up", () => { + mockUseAppSelector.mockImplementation((selector) => { + if (selector === selectGoogleMetadata) { + return { + connectionStatus: "not_connected", + syncStatus: "none", + }; + } + + if (selector === selectImportGCalState) { + return { + importing: true, + isImportPending: true, + }; + } + + return undefined; + }); + + const { result } = renderHook(() => useConnectGoogle()); + + expect(result.current.commandAction.isDisabled).toBe(true); + expect(result.current.sidebarStatus.icon).toBe("SpinnerIcon"); + expect(result.current.sidebarStatus.isDisabled).toBe(true); + }); }); diff --git a/packages/web/src/auth/hooks/oauth/useConnectGoogle.ts b/packages/web/src/auth/hooks/oauth/useConnectGoogle.ts index d623db138..77b8242f4 100644 --- a/packages/web/src/auth/hooks/oauth/useConnectGoogle.ts +++ b/packages/web/src/auth/hooks/oauth/useConnectGoogle.ts @@ -4,7 +4,10 @@ import { type GoogleSyncStatus, } from "@core/types/user.types"; import { useGoogleAuth } from "@web/auth/hooks/oauth/useGoogleAuth"; +import { SyncApi } from "@web/common/apis/sync.api"; import { selectGoogleMetadata } from "@web/ducks/auth/selectors/user-metadata.selectors"; +import { selectImportGCalState } from "@web/ducks/events/selectors/sync.selector"; +import { importGCalSlice } from "@web/ducks/events/slices/sync.slice"; import { settingsSlice } from "@web/ducks/settings/slices/settings.slice"; import { useAppDispatch, useAppSelector } from "@web/store/store.hooks"; @@ -45,10 +48,16 @@ const COMMAND_ICON: CommandActionIcon = "CloudArrowUpIcon"; const getGoogleUiState = ({ connectionStatus, syncStatus, + isImporting, }: { connectionStatus: GoogleConnectionStatus; syncStatus: GoogleSyncStatus; + isImporting: boolean; }): GoogleUiState => { + if (isImporting) { + return "connected_repairing"; + } + if (connectionStatus === "reconnect_required") { return "reconnect_required"; } @@ -70,7 +79,8 @@ const getGoogleUiState = ({ const getGoogleUiConfig = ( state: GoogleUiState, - onOpenGoogleAuth: () => void, + onConnectGoogle: () => void, + onRepairGoogle: () => void, ): GoogleUiConfig => { switch (state) { case "not_connected": @@ -79,13 +89,13 @@ const getGoogleUiConfig = ( label: COMMAND_LABEL, icon: COMMAND_ICON, isDisabled: false, - onSelect: onOpenGoogleAuth, + onSelect: onConnectGoogle, }, sidebarStatus: { icon: "CloudArrowUpIcon", tooltip: "Google Calendar not connected. Click to connect.", isDisabled: false, - onSelect: onOpenGoogleAuth, + onSelect: onConnectGoogle, }, }; case "reconnect_required": @@ -94,13 +104,13 @@ const getGoogleUiConfig = ( label: COMMAND_LABEL, icon: COMMAND_ICON, isDisabled: false, - onSelect: onOpenGoogleAuth, + onSelect: onConnectGoogle, }, sidebarStatus: { icon: "LinkBreakIcon", tooltip: "Google Calendar needs reconnecting. Click to reconnect.", isDisabled: false, - onSelect: onOpenGoogleAuth, + onSelect: onConnectGoogle, }, }; case "connected_repairing": @@ -122,13 +132,13 @@ const getGoogleUiConfig = ( label: COMMAND_LABEL, icon: COMMAND_ICON, isDisabled: false, - onSelect: onOpenGoogleAuth, + onSelect: onRepairGoogle, }, sidebarStatus: { icon: "CloudWarningIcon", - tooltip: "Google Calendar needs repair. Click to reconnect.", + tooltip: "Google Calendar needs repair. Click to repair.", isDisabled: false, - onSelect: onOpenGoogleAuth, + onSelect: onRepairGoogle, }, }; case "connected_healthy": @@ -150,6 +160,7 @@ const getGoogleUiConfig = ( export const useConnectGoogle = () => { const dispatch = useAppDispatch(); const googleMetadata = useAppSelector(selectGoogleMetadata); + const importGCal = useAppSelector(selectImportGCalState); const { login } = useGoogleAuth(); const onOpenGoogleAuth = useCallback(() => { @@ -157,9 +168,36 @@ export const useConnectGoogle = () => { dispatch(settingsSlice.actions.closeCmdPalette()); }, [dispatch, login]); + const onRepairGoogleCalendar = useCallback(() => { + const run = async () => { + dispatch(importGCalSlice.actions.clearImportResults(undefined)); + dispatch(importGCalSlice.actions.setIsImportPending(true)); + dispatch(importGCalSlice.actions.importing(true)); + dispatch(settingsSlice.actions.closeCmdPalette()); + + try { + await SyncApi.importGCal({ force: true }); + } catch (error) { + console.error("Failed to start Google Calendar repair:", error); + dispatch(importGCalSlice.actions.setIsImportPending(false)); + dispatch(importGCalSlice.actions.importing(false)); + dispatch( + importGCalSlice.actions.setImportError( + "Failed to start Google Calendar repair.", + ), + ); + } + }; + void run(); + }, [dispatch]); + const connectionStatus = googleMetadata?.connectionStatus ?? "not_connected"; const syncStatus = googleMetadata?.syncStatus ?? "none"; - const state = getGoogleUiState({ connectionStatus, syncStatus }); + const state = getGoogleUiState({ + connectionStatus, + syncStatus, + isImporting: importGCal.importing || importGCal.isImportPending, + }); - return getGoogleUiConfig(state, onOpenGoogleAuth); + return getGoogleUiConfig(state, onOpenGoogleAuth, onRepairGoogleCalendar); }; diff --git a/packages/web/src/common/apis/sync.api.ts b/packages/web/src/common/apis/sync.api.ts index 1a768d0f2..cc8d19bb7 100644 --- a/packages/web/src/common/apis/sync.api.ts +++ b/packages/web/src/common/apis/sync.api.ts @@ -1,8 +1,8 @@ import { CompassApi } from "./compass.api"; const SyncApi = { - async importGCal() { - return CompassApi.post(`/sync/import-gcal`); + async importGCal(options?: { force?: boolean }) { + return CompassApi.post(`/sync/import-gcal`, options); }, }; diff --git a/packages/web/src/socket/hooks/useGcalSync.test.ts b/packages/web/src/socket/hooks/useGcalSync.test.ts index 840ad549a..c3194b004 100644 --- a/packages/web/src/socket/hooks/useGcalSync.test.ts +++ b/packages/web/src/socket/hooks/useGcalSync.test.ts @@ -1,6 +1,7 @@ import { useDispatch } from "react-redux"; import { renderHook } from "@testing-library/react"; import { + FETCH_USER_METADATA, GOOGLE_REVOKED, IMPORT_GCAL_END, IMPORT_GCAL_START, @@ -28,6 +29,7 @@ jest.mock("@web/store/store.hooks", () => ({ })); jest.mock("../client/socket.client", () => ({ socket: { + emit: jest.fn(), on: jest.fn(), removeListener: jest.fn(), }, @@ -292,6 +294,7 @@ describe("useGcalSync", () => { calendarsCount: 2, }), ); + expect(socket.emit).toHaveBeenCalledWith(FETCH_USER_METADATA); expect(triggerFetch).toHaveBeenCalledWith({ reason: "IMPORT_COMPLETE", }); @@ -314,6 +317,7 @@ describe("useGcalSync", () => { expect(mockDispatch).toHaveBeenCalledWith( importGCalSlice.actions.importing(false), ); + expect(socket.emit).toHaveBeenCalledWith(FETCH_USER_METADATA); expect(importGCalSlice.actions.setImportResults).not.toHaveBeenCalled(); }); @@ -348,6 +352,7 @@ describe("useGcalSync", () => { calendarsCount: 2, }), ); + expect(socket.emit).toHaveBeenCalledWith(FETCH_USER_METADATA); }); }); @@ -406,6 +411,7 @@ describe("useGcalSync", () => { calendarsCount: 3, }), ); + expect(socket.emit).toHaveBeenCalledWith(FETCH_USER_METADATA); // Fetch should be triggered to load new events expect(triggerFetch).toHaveBeenCalledWith({ reason: "IMPORT_COMPLETE", @@ -438,6 +444,7 @@ describe("useGcalSync", () => { expect(mockDispatch).toHaveBeenCalledWith( importGCalSlice.actions.importing(false), ); + expect(socket.emit).toHaveBeenCalledWith(FETCH_USER_METADATA); }); it("handles rapid start/end sequence without state inconsistency", () => { @@ -466,6 +473,7 @@ describe("useGcalSync", () => { ); expect(importGCalSlice.actions.importing).toHaveBeenCalledWith(true); expect(importGCalSlice.actions.importing).toHaveBeenCalledWith(false); + expect(socket.emit).toHaveBeenCalledWith(FETCH_USER_METADATA); expect(importGCalSlice.actions.setImportResults).toHaveBeenCalledWith({ eventsCount: 2, calendarsCount: 1, @@ -494,6 +502,7 @@ describe("useGcalSync", () => { expect(mockDispatch).toHaveBeenCalledWith( importGCalSlice.actions.setImportResults({}), ); + expect(socket.emit).toHaveBeenCalledWith(FETCH_USER_METADATA); }); it("handles import end with object payload (non-string)", () => { @@ -520,6 +529,7 @@ describe("useGcalSync", () => { calendarsCount: 4, }), ); + expect(socket.emit).toHaveBeenCalledWith(FETCH_USER_METADATA); }); it("sets error state when backend returns malformed JSON", () => { @@ -550,6 +560,7 @@ describe("useGcalSync", () => { "Failed to parse Google Calendar import results.", ), ); + expect(socket.emit).toHaveBeenCalledWith(FETCH_USER_METADATA); // Should NOT set results expect(importGCalSlice.actions.setImportResults).not.toHaveBeenCalled(); diff --git a/packages/web/src/socket/hooks/useGcalSync.ts b/packages/web/src/socket/hooks/useGcalSync.ts index 02242be3b..6509d25ae 100644 --- a/packages/web/src/socket/hooks/useGcalSync.ts +++ b/packages/web/src/socket/hooks/useGcalSync.ts @@ -1,6 +1,7 @@ import { useCallback, useEffect, useRef } from "react"; import { useDispatch } from "react-redux"; import { + FETCH_USER_METADATA, GOOGLE_REVOKED, IMPORT_GCAL_END, IMPORT_GCAL_START, @@ -39,6 +40,7 @@ export const useGcalSync = () => { const onImportEnd = useCallback( (payload?: { eventsCount?: number; calendarsCount?: number } | string) => { dispatch(importGCalSlice.actions.importing(false)); + socket.emit(FETCH_USER_METADATA); if (!isImportPendingRef.current) { return; diff --git a/packages/web/src/views/Calendar/components/Sidebar/SidebarIconRow/SidebarIconRow.test.tsx b/packages/web/src/views/Calendar/components/Sidebar/SidebarIconRow/SidebarIconRow.test.tsx index d3a424035..de5b23615 100644 --- a/packages/web/src/views/Calendar/components/Sidebar/SidebarIconRow/SidebarIconRow.test.tsx +++ b/packages/web/src/views/Calendar/components/Sidebar/SidebarIconRow/SidebarIconRow.test.tsx @@ -1,6 +1,7 @@ import type { ReactNode } from "react"; import { fireEvent, screen } from "@testing-library/react"; import { render } from "@web/__tests__/__mocks__/mock.render"; +import { SyncApi } from "@web/common/apis/sync.api"; import { SidebarIconRow } from "@web/views/Calendar/components/Sidebar/SidebarIconRow"; const mockLogin = jest.fn(); @@ -11,6 +12,12 @@ jest.mock("@web/auth/hooks/oauth/useGoogleAuth", () => ({ }), })); +jest.mock("@web/common/apis/sync.api", () => ({ + SyncApi: { + importGCal: jest.fn().mockResolvedValue(undefined), + }, +})); + jest.mock("@web/common/hooks/useVersionCheck", () => ({ useVersionCheck: () => ({ isUpdateAvailable: false, @@ -157,10 +164,10 @@ describe("SidebarIconRow", () => { fireEvent.click( screen.getByRole("button", { - name: "Google Calendar needs repair. Click to reconnect.", + name: "Google Calendar needs repair. Click to repair.", }), ); - expect(mockLogin).toHaveBeenCalled(); + expect(SyncApi.importGCal).toHaveBeenCalledWith({ force: true }); }); }); diff --git a/packages/web/src/views/CmdPalette/CmdPalette.test.tsx b/packages/web/src/views/CmdPalette/CmdPalette.test.tsx index d0e2a5f88..75892f628 100644 --- a/packages/web/src/views/CmdPalette/CmdPalette.test.tsx +++ b/packages/web/src/views/CmdPalette/CmdPalette.test.tsx @@ -2,6 +2,7 @@ import { act } from "react"; import { fireEvent, screen, waitFor } from "@testing-library/react"; import dayjs from "@core/util/date/dayjs"; import { render } from "@web/__tests__/__mocks__/mock.render"; +import { SyncApi } from "@web/common/apis/sync.api"; import CmdPalette from "@web/views/CmdPalette/CmdPalette"; jest.mock("react-cmdk", () => { @@ -63,6 +64,12 @@ jest.mock("@web/auth/hooks/oauth/useGoogleAuth", () => ({ }), })); +jest.mock("@web/common/apis/sync.api", () => ({ + SyncApi: { + importGCal: jest.fn().mockResolvedValue(undefined), + }, +})); + const baseProps = { today: dayjs(), isCurrentWeek: true, @@ -111,7 +118,7 @@ describe("CmdPalette", () => { ).toBeDisabled(); }); - it("still triggers auth when Google Calendar needs repair", async () => { + it("starts a forced repair when Google Calendar needs repair", async () => { render(, { state: { settings: { isCmdPaletteOpen: true }, @@ -132,6 +139,8 @@ describe("CmdPalette", () => { ), ); - await waitFor(() => expect(mockLogin).toHaveBeenCalled()); + await waitFor(() => + expect(SyncApi.importGCal).toHaveBeenCalledWith({ force: true }), + ); }); }); diff --git a/packages/web/src/views/CmdPalette/CmdPalette.tsx b/packages/web/src/views/CmdPalette/CmdPalette.tsx index 2082f3b2c..1e04c2a4a 100644 --- a/packages/web/src/views/CmdPalette/CmdPalette.tsx +++ b/packages/web/src/views/CmdPalette/CmdPalette.tsx @@ -81,43 +81,43 @@ const CmdPalette = ({ id: "create-event", children: "Create Event [c]", icon: "PlusIcon", - onClick: onEventTargetVisibility(() => - createTimedDraft( + onClick: onEventTargetVisibility(() => { + void createTimedDraft( isCurrentWeek, startOfView, "createShortcut", dispatch, - ), - ), + ); + }), }, { id: "create-allday-event", children: "Create All-Day Event [a]", icon: "PlusIcon", - onClick: onEventTargetVisibility(() => - createAlldayDraft( + onClick: onEventTargetVisibility(() => { + void createAlldayDraft( startOfView, endOfView, "createShortcut", dispatch, - ), - ), + ); + }), }, { id: "create-someday-week-event", children: "Create Week Event [w]", icon: "PlusIcon", - onClick: onEventTargetVisibility(() => - handleCreateSomedayDraft(Categories_Event.SOMEDAY_WEEK), - ), + onClick: onEventTargetVisibility(() => { + void handleCreateSomedayDraft(Categories_Event.SOMEDAY_WEEK); + }), }, { id: "create-someday-month-event", children: "Create Month Event [m]", icon: "PlusIcon", - onClick: onEventTargetVisibility(() => - handleCreateSomedayDraft(Categories_Event.SOMEDAY_MONTH), - ), + onClick: onEventTargetVisibility(() => { + void handleCreateSomedayDraft(Categories_Event.SOMEDAY_MONTH); + }), }, { id: "today", diff --git a/packages/web/src/views/Day/components/DayCmdPalette.test.tsx b/packages/web/src/views/Day/components/DayCmdPalette.test.tsx index d78367096..c881c4ab6 100644 --- a/packages/web/src/views/Day/components/DayCmdPalette.test.tsx +++ b/packages/web/src/views/Day/components/DayCmdPalette.test.tsx @@ -3,6 +3,7 @@ import { fireEvent, screen } from "@testing-library/react"; import userEvent from "@testing-library/user-event"; import { render } from "@web/__tests__/__mocks__/mock.render"; import * as useGoogleAuthModule from "@web/auth/hooks/oauth/useGoogleAuth"; +import { SyncApi } from "@web/common/apis/sync.api"; import { ROOT_ROUTES } from "@web/common/constants/routes"; import { keyPressed$ } from "@web/common/utils/dom/event-emitter.util"; import * as eventUtil from "@web/common/utils/event/event.util"; @@ -46,6 +47,12 @@ jest.mock("@web/store/store.hooks", () => ({ useAppSelector: jest.requireActual("@web/store/store.hooks").useAppSelector, })); +jest.mock("@web/common/apis/sync.api", () => ({ + SyncApi: { + importGCal: jest.fn().mockResolvedValue(undefined), + }, +})); + function Component() { useGlobalShortcuts(); @@ -345,7 +352,7 @@ describe("DayCmdPalette", () => { await act(() => user.click(screen.getByText("Connect Google Calendar"))); - expect(mockLogin).toHaveBeenCalled(); + expect(SyncApi.importGCal).toHaveBeenCalledWith({ force: true }); }); }); }); diff --git a/packages/web/src/views/Now/components/NowCmdPalette.test.tsx b/packages/web/src/views/Now/components/NowCmdPalette.test.tsx index 42bccdac8..cc89c81c8 100644 --- a/packages/web/src/views/Now/components/NowCmdPalette.test.tsx +++ b/packages/web/src/views/Now/components/NowCmdPalette.test.tsx @@ -1,6 +1,7 @@ import { act } from "react"; import { fireEvent, screen, waitFor } from "@testing-library/react"; import { render } from "@web/__tests__/__mocks__/mock.render"; +import { SyncApi } from "@web/common/apis/sync.api"; import { pressKey } from "@web/common/utils/dom/event-emitter.util"; import { NowCmdPalette } from "@web/views/Now/components/NowCmdPalette"; @@ -71,6 +72,12 @@ jest.mock("@web/auth/hooks/oauth/useGoogleAuth", () => ({ }), })); +jest.mock("@web/common/apis/sync.api", () => ({ + SyncApi: { + importGCal: jest.fn().mockResolvedValue(undefined), + }, +})); + describe("NowCmdPalette", () => { const initialState = { settings: { @@ -211,7 +218,7 @@ describe("NowCmdPalette", () => { expect(button).toBeDisabled(); }); - it("keeps the generic action enabled when sync needs attention", async () => { + it("starts a forced repair when sync needs attention", async () => { render(, { state: { ...initialState, @@ -232,7 +239,9 @@ describe("NowCmdPalette", () => { ), ); - await waitFor(() => expect(mockLogin).toHaveBeenCalled()); + await waitFor(() => + expect(SyncApi.importGCal).toHaveBeenCalledWith({ force: true }), + ); }); }); }); From 627b3d36e236c8d2ef845af56c6d4dac25d2c739 Mon Sep 17 00:00:00 2001 From: Tyler Dane Date: Sat, 7 Mar 2026 17:04:43 -0800 Subject: [PATCH 11/24] docs(tests): update testing guidelines to avoid direct persistence layer imports - Added a note in testing documentation to discourage importing `mongoService` or other persistence layers directly in tests. - Recommended using test drivers (e.g., `UserDriver`, `WatchDriver`) to maintain test agnosticism towards the backing store, facilitating easier transitions away from MongoDB in the future. --- .cursorrules/testing.md | 1 + docs/testing-playbook.md | 3 +++ 2 files changed, 4 insertions(+) diff --git a/.cursorrules/testing.md b/.cursorrules/testing.md index a14b65e88..8d86f87b6 100644 --- a/.cursorrules/testing.md +++ b/.cursorrules/testing.md @@ -73,6 +73,7 @@ const button = container.querySelector('.add-button'); - Use async/await for asynchronous tests - Mock external services (Google Calendar API, MongoDB) appropriately - Test error handling and edge cases +- **Do not import `mongoService` or other persistence layers directly in tests.** Use the test drivers in `packages/backend/src/__tests__/drivers/` (e.g. `UserDriver`, `WatchDriver`) so that tests stay agnostic of the backing store and switching away from Mongo later does not require test changes. **Real examples:** diff --git a/docs/testing-playbook.md b/docs/testing-playbook.md index c0716e413..0bd92b167 100644 --- a/docs/testing-playbook.md +++ b/docs/testing-playbook.md @@ -209,9 +209,12 @@ Preferred style: - realistic request flows when possible - mock only external services, not internal business logic +**Do not import `mongoService` (or other persistence implementations) directly in tests.** Use test drivers instead (e.g. `UserDriver`, `WatchDriver` in `packages/backend/src/__tests__/drivers/`). Drivers encapsulate persistence so that switching away from Mongo (or another store) in the future does not require changing test code. + Useful anchors: - `packages/backend/src/__tests__` +- `packages/backend/src/__tests__/drivers/` - `packages/backend/src/event/services/*.test.ts` - `packages/backend/src/sync/**/*.test.ts` From 9be4a329b0ca0d4eb0dde180f4fb06fd7317e913 Mon Sep 17 00:00:00 2001 From: Cursor Agent Date: Sun, 8 Mar 2026 01:24:05 +0000 Subject: [PATCH 12/24] fix(web): use state-specific labels for Google Calendar command palette Co-authored-by: Tyler Dane --- packages/web/src/auth/hooks/oauth/useConnectGoogle.ts | 11 +++++------ 1 file changed, 5 insertions(+), 6 deletions(-) diff --git a/packages/web/src/auth/hooks/oauth/useConnectGoogle.ts b/packages/web/src/auth/hooks/oauth/useConnectGoogle.ts index 77b8242f4..8a5731675 100644 --- a/packages/web/src/auth/hooks/oauth/useConnectGoogle.ts +++ b/packages/web/src/auth/hooks/oauth/useConnectGoogle.ts @@ -42,7 +42,6 @@ type GoogleUiConfig = { }; }; -const COMMAND_LABEL = "Connect Google Calendar"; const COMMAND_ICON: CommandActionIcon = "CloudArrowUpIcon"; const getGoogleUiState = ({ @@ -86,7 +85,7 @@ const getGoogleUiConfig = ( case "not_connected": return { commandAction: { - label: COMMAND_LABEL, + label: "Connect Google Calendar", icon: COMMAND_ICON, isDisabled: false, onSelect: onConnectGoogle, @@ -101,7 +100,7 @@ const getGoogleUiConfig = ( case "reconnect_required": return { commandAction: { - label: COMMAND_LABEL, + label: "Reconnect Google Calendar", icon: COMMAND_ICON, isDisabled: false, onSelect: onConnectGoogle, @@ -116,7 +115,7 @@ const getGoogleUiConfig = ( case "connected_repairing": return { commandAction: { - label: COMMAND_LABEL, + label: "Syncing Google Calendar…", icon: COMMAND_ICON, isDisabled: true, }, @@ -129,7 +128,7 @@ const getGoogleUiConfig = ( case "connected_attention": return { commandAction: { - label: COMMAND_LABEL, + label: "Repair Google Calendar", icon: COMMAND_ICON, isDisabled: false, onSelect: onRepairGoogle, @@ -144,7 +143,7 @@ const getGoogleUiConfig = ( case "connected_healthy": return { commandAction: { - label: COMMAND_LABEL, + label: "Google Calendar Connected", icon: COMMAND_ICON, isDisabled: true, }, From 7eae7d4af0a705992d32eceaa76c32451123df12 Mon Sep 17 00:00:00 2001 From: Cursor Agent Date: Sun, 8 Mar 2026 02:26:22 +0000 Subject: [PATCH 13/24] test(web): update test assertions to match state-specific Google Calendar labels Co-authored-by: Tyler Dane --- .../src/auth/hooks/oauth/useConnectGoogle.test.ts | 12 ++++++++---- .../web/src/views/CmdPalette/CmdPalette.test.tsx | 4 ++-- .../src/views/Day/components/DayCmdPalette.test.tsx | 10 ++++++---- .../src/views/Now/components/NowCmdPalette.test.tsx | 8 ++++---- 4 files changed, 20 insertions(+), 14 deletions(-) diff --git a/packages/web/src/auth/hooks/oauth/useConnectGoogle.test.ts b/packages/web/src/auth/hooks/oauth/useConnectGoogle.test.ts index 80d797e52..fecc7e397 100644 --- a/packages/web/src/auth/hooks/oauth/useConnectGoogle.test.ts +++ b/packages/web/src/auth/hooks/oauth/useConnectGoogle.test.ts @@ -84,7 +84,9 @@ describe("useConnectGoogle", () => { const { result } = renderHook(() => useConnectGoogle()); - expect(result.current.commandAction.label).toBe("Connect Google Calendar"); + expect(result.current.commandAction.label).toBe( + "Google Calendar Connected", + ); expect(result.current.commandAction.isDisabled).toBe(true); expect(result.current.commandAction.onSelect).toBeUndefined(); expect(result.current.sidebarStatus.icon).toBe("CheckCircleIcon"); @@ -112,7 +114,9 @@ describe("useConnectGoogle", () => { const { result } = renderHook(() => useConnectGoogle()); - expect(result.current.commandAction.label).toBe("Connect Google Calendar"); + expect(result.current.commandAction.label).toBe( + "Reconnect Google Calendar", + ); expect(result.current.sidebarStatus.icon).toBe("LinkBreakIcon"); result.current.commandAction.onSelect?.(); @@ -143,7 +147,7 @@ describe("useConnectGoogle", () => { const { result } = renderHook(() => useConnectGoogle()); - expect(result.current.commandAction.label).toBe("Connect Google Calendar"); + expect(result.current.commandAction.label).toBe("Syncing Google Calendar…"); expect(result.current.commandAction.isDisabled).toBe(true); expect(result.current.commandAction.onSelect).toBeUndefined(); expect(result.current.sidebarStatus.icon).toBe("SpinnerIcon"); @@ -171,7 +175,7 @@ describe("useConnectGoogle", () => { const { result } = renderHook(() => useConnectGoogle()); - expect(result.current.commandAction.label).toBe("Connect Google Calendar"); + expect(result.current.commandAction.label).toBe("Repair Google Calendar"); expect(result.current.commandAction.isDisabled).toBe(false); expect(result.current.sidebarStatus.icon).toBe("CloudWarningIcon"); expect(result.current.sidebarStatus.tooltip).toBe( diff --git a/packages/web/src/views/CmdPalette/CmdPalette.test.tsx b/packages/web/src/views/CmdPalette/CmdPalette.test.tsx index 75892f628..7b4dc9edd 100644 --- a/packages/web/src/views/CmdPalette/CmdPalette.test.tsx +++ b/packages/web/src/views/CmdPalette/CmdPalette.test.tsx @@ -114,7 +114,7 @@ describe("CmdPalette", () => { }); expect( - screen.getByRole("button", { name: /connect google calendar/i }), + screen.getByRole("button", { name: "Google Calendar Connected" }), ).toBeDisabled(); }); @@ -135,7 +135,7 @@ describe("CmdPalette", () => { act(() => fireEvent.click( - screen.getByRole("button", { name: /connect google calendar/i }), + screen.getByRole("button", { name: "Repair Google Calendar" }), ), ); diff --git a/packages/web/src/views/Day/components/DayCmdPalette.test.tsx b/packages/web/src/views/Day/components/DayCmdPalette.test.tsx index c881c4ab6..eccb82dfb 100644 --- a/packages/web/src/views/Day/components/DayCmdPalette.test.tsx +++ b/packages/web/src/views/Day/components/DayCmdPalette.test.tsx @@ -280,7 +280,7 @@ describe("DayCmdPalette", () => { ); expect( - screen.getByText("Connect Google Calendar").closest("button"), + screen.getByText("Google Calendar Connected").closest("button"), ).toHaveAttribute("aria-disabled", "true"); }); @@ -302,7 +302,9 @@ describe("DayCmdPalette", () => { }), ); - await act(() => user.click(screen.getByText("Connect Google Calendar"))); + await act(() => + user.click(screen.getByText("Reconnect Google Calendar")), + ); expect(mockLogin).toHaveBeenCalled(); expect(mockDispatch).toHaveBeenCalledWith( @@ -328,7 +330,7 @@ describe("DayCmdPalette", () => { ); expect( - screen.getByText("Connect Google Calendar").closest("button"), + screen.getByText("Syncing Google Calendar…").closest("button"), ).toHaveAttribute("aria-disabled", "true"); }); @@ -350,7 +352,7 @@ describe("DayCmdPalette", () => { }), ); - await act(() => user.click(screen.getByText("Connect Google Calendar"))); + await act(() => user.click(screen.getByText("Repair Google Calendar"))); expect(SyncApi.importGCal).toHaveBeenCalledWith({ force: true }); }); diff --git a/packages/web/src/views/Now/components/NowCmdPalette.test.tsx b/packages/web/src/views/Now/components/NowCmdPalette.test.tsx index cc89c81c8..e01d7cdb0 100644 --- a/packages/web/src/views/Now/components/NowCmdPalette.test.tsx +++ b/packages/web/src/views/Now/components/NowCmdPalette.test.tsx @@ -170,7 +170,7 @@ describe("NowCmdPalette", () => { }); expect( - screen.getByRole("button", { name: "Connect Google Calendar" }), + screen.getByRole("button", { name: "Google Calendar Connected" }), ).toBeDisabled(); }); @@ -191,7 +191,7 @@ describe("NowCmdPalette", () => { act(() => fireEvent.click( - screen.getByRole("button", { name: "Connect Google Calendar" }), + screen.getByRole("button", { name: "Reconnect Google Calendar" }), ), ); await waitFor(() => expect(mockLogin).toHaveBeenCalled()); @@ -213,7 +213,7 @@ describe("NowCmdPalette", () => { }); const button = screen.getByRole("button", { - name: "Connect Google Calendar", + name: "Syncing Google Calendar…", }); expect(button).toBeDisabled(); }); @@ -235,7 +235,7 @@ describe("NowCmdPalette", () => { act(() => fireEvent.click( - screen.getByRole("button", { name: "Connect Google Calendar" }), + screen.getByRole("button", { name: "Repair Google Calendar" }), ), ); From 9ca556212766370cd1209cde367392dac62e49ec Mon Sep 17 00:00:00 2001 From: Cursor Agent Date: Sun, 8 Mar 2026 02:52:02 +0000 Subject: [PATCH 14/24] fix(web): prioritize reconnect_required over importing state in Google UI fix(backend): add race condition check before scheduling Google repair fix(web): only close command palette when repair triggered from command palette Co-authored-by: Tyler Dane --- .../user/services/user-metadata.service.ts | 9 +++++ .../auth/hooks/oauth/useConnectGoogle.test.ts | 37 +++++++++++++++++++ .../src/auth/hooks/oauth/useConnectGoogle.ts | 30 +++++++++++---- 3 files changed, 68 insertions(+), 8 deletions(-) diff --git a/packages/backend/src/user/services/user-metadata.service.ts b/packages/backend/src/user/services/user-metadata.service.ts index 7dce7f464..9ce283564 100644 --- a/packages/backend/src/user/services/user-metadata.service.ts +++ b/packages/backend/src/user/services/user-metadata.service.ts @@ -177,6 +177,15 @@ class UserMetadataService { }; } + if (this.hasPendingGoogleRepair(userId)) { + return { + hasRefreshToken, + connectionStatus, + syncStatus: "repairing", + scheduledRepair: false, + }; + } + const scheduledRepair = this.scheduleGoogleRepair(userId); return { diff --git a/packages/web/src/auth/hooks/oauth/useConnectGoogle.test.ts b/packages/web/src/auth/hooks/oauth/useConnectGoogle.test.ts index fecc7e397..cce4697db 100644 --- a/packages/web/src/auth/hooks/oauth/useConnectGoogle.test.ts +++ b/packages/web/src/auth/hooks/oauth/useConnectGoogle.test.ts @@ -194,6 +194,15 @@ describe("useConnectGoogle", () => { expect(mockDispatch).toHaveBeenCalledWith( importGCalSlice.actions.importing(true), ); + expect(mockDispatch).not.toHaveBeenCalledWith( + settingsSlice.actions.closeCmdPalette(), + ); + + jest.clearAllMocks(); + + result.current.commandAction.onSelect?.(); + + expect(mockSyncApi.importGCal).toHaveBeenCalledWith({ force: true }); expect(mockDispatch).toHaveBeenCalledWith( settingsSlice.actions.closeCmdPalette(), ); @@ -224,4 +233,32 @@ describe("useConnectGoogle", () => { expect(result.current.sidebarStatus.icon).toBe("SpinnerIcon"); expect(result.current.sidebarStatus.isDisabled).toBe(true); }); + + it("prioritizes reconnect_required over importing state", () => { + mockUseAppSelector.mockImplementation((selector) => { + if (selector === selectGoogleMetadata) { + return { + connectionStatus: "reconnect_required", + syncStatus: "none", + }; + } + + if (selector === selectImportGCalState) { + return { + importing: true, + isImportPending: true, + }; + } + + return undefined; + }); + + const { result } = renderHook(() => useConnectGoogle()); + + expect(result.current.commandAction.label).toBe( + "Reconnect Google Calendar", + ); + expect(result.current.sidebarStatus.icon).toBe("LinkBreakIcon"); + expect(result.current.commandAction.isDisabled).toBe(false); + }); }); diff --git a/packages/web/src/auth/hooks/oauth/useConnectGoogle.ts b/packages/web/src/auth/hooks/oauth/useConnectGoogle.ts index 8a5731675..161022109 100644 --- a/packages/web/src/auth/hooks/oauth/useConnectGoogle.ts +++ b/packages/web/src/auth/hooks/oauth/useConnectGoogle.ts @@ -53,14 +53,14 @@ const getGoogleUiState = ({ syncStatus: GoogleSyncStatus; isImporting: boolean; }): GoogleUiState => { - if (isImporting) { - return "connected_repairing"; - } - if (connectionStatus === "reconnect_required") { return "reconnect_required"; } + if (isImporting) { + return "connected_repairing"; + } + if (connectionStatus === "connected" && syncStatus === "repairing") { return "connected_repairing"; } @@ -80,6 +80,7 @@ const getGoogleUiConfig = ( state: GoogleUiState, onConnectGoogle: () => void, onRepairGoogle: () => void, + onRepairGoogleFromSidebar: () => void, ): GoogleUiConfig => { switch (state) { case "not_connected": @@ -137,7 +138,7 @@ const getGoogleUiConfig = ( icon: "CloudWarningIcon", tooltip: "Google Calendar needs repair. Click to repair.", isDisabled: false, - onSelect: onRepairGoogle, + onSelect: onRepairGoogleFromSidebar, }, }; case "connected_healthy": @@ -167,12 +168,11 @@ export const useConnectGoogle = () => { dispatch(settingsSlice.actions.closeCmdPalette()); }, [dispatch, login]); - const onRepairGoogleCalendar = useCallback(() => { + const onRepairGoogleCalendarBase = useCallback(() => { const run = async () => { dispatch(importGCalSlice.actions.clearImportResults(undefined)); dispatch(importGCalSlice.actions.setIsImportPending(true)); dispatch(importGCalSlice.actions.importing(true)); - dispatch(settingsSlice.actions.closeCmdPalette()); try { await SyncApi.importGCal({ force: true }); @@ -190,6 +190,15 @@ export const useConnectGoogle = () => { void run(); }, [dispatch]); + const onRepairGoogleCalendar = useCallback(() => { + dispatch(settingsSlice.actions.closeCmdPalette()); + onRepairGoogleCalendarBase(); + }, [dispatch, onRepairGoogleCalendarBase]); + + const onRepairGoogleCalendarFromSidebar = useCallback(() => { + onRepairGoogleCalendarBase(); + }, [onRepairGoogleCalendarBase]); + const connectionStatus = googleMetadata?.connectionStatus ?? "not_connected"; const syncStatus = googleMetadata?.syncStatus ?? "none"; const state = getGoogleUiState({ @@ -198,5 +207,10 @@ export const useConnectGoogle = () => { isImporting: importGCal.importing || importGCal.isImportPending, }); - return getGoogleUiConfig(state, onOpenGoogleAuth, onRepairGoogleCalendar); + return getGoogleUiConfig( + state, + onOpenGoogleAuth, + onRepairGoogleCalendar, + onRepairGoogleCalendarFromSidebar, + ); }; From 6c46ccf2b8c972c70545b0c583e0e9fecbe36151 Mon Sep 17 00:00:00 2001 From: Cursor Agent Date: Sun, 8 Mar 2026 03:11:12 +0000 Subject: [PATCH 15/24] fix(backend): remove unused driver methods after local refactor Co-authored-by: Tyler Dane --- .../drivers/sync.controller.driver.ts | 31 ------------------- 1 file changed, 31 deletions(-) diff --git a/packages/backend/src/__tests__/drivers/sync.controller.driver.ts b/packages/backend/src/__tests__/drivers/sync.controller.driver.ts index 6aa12f93e..bb8e0ae36 100644 --- a/packages/backend/src/__tests__/drivers/sync.controller.driver.ts +++ b/packages/backend/src/__tests__/drivers/sync.controller.driver.ts @@ -1,9 +1,5 @@ import request from "supertest"; import { GCAL_NOTIFICATION_ENDPOINT } from "@core/constants/core.constants"; -import { - IMPORT_GCAL_END, - IMPORT_GCAL_START, -} from "@core/constants/websocket.constants"; import { Status } from "@core/errors/status.codes"; import { type Payload_Sync_Notif } from "@core/types/sync.types"; import { type BaseDriver } from "@backend/__tests__/drivers/base.driver"; @@ -12,33 +8,6 @@ import { encodeChannelToken } from "@backend/sync/util/watch.util"; export class SyncControllerDriver { constructor(private readonly baseDriver: BaseDriver) {} - async waitUntilImportGCalStart( - websocketClient: ReturnType, - beforeEvent: () => Promise = () => Promise.resolve(), - afterEvent: (...args: void[]) => Promise = (...args) => - Promise.resolve(args as Result), - ): Promise { - return this.baseDriver.waitUntilWebsocketEvent( - websocketClient, - IMPORT_GCAL_START, - beforeEvent, - afterEvent, - ); - } - - async waitUntilImportGCalEnd( - websocketClient: ReturnType, - beforeEvent: () => Promise = () => Promise.resolve(), - afterEvent: (...args: [string | undefined]) => Promise = ( - ...args - ) => Promise.resolve(args as Result), - ): Promise { - return this.baseDriver.waitUntilWebsocketEvent< - [string | undefined], - Result - >(websocketClient, IMPORT_GCAL_END, beforeEvent, afterEvent); - } - async handleGoogleNotification( { token, From 092f48e625727041af20ba1a4ebc5e7edc596f57 Mon Sep 17 00:00:00 2001 From: Tyler Dane Date: Sat, 7 Mar 2026 17:05:47 -0800 Subject: [PATCH 16/24] fix(husky): run lint-staged in quiet mode during pre-commit hook --- .husky/pre-commit | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/.husky/pre-commit b/.husky/pre-commit index d2ae35e84..2ae3c6a6e 100755 --- a/.husky/pre-commit +++ b/.husky/pre-commit @@ -1,4 +1,4 @@ #!/bin/sh . "$(dirname "$0")/_/husky.sh" -yarn lint-staged +yarn lint-staged --quiet From 590234190e0ca48a9f88ffe9b750e2553ce33a07 Mon Sep 17 00:00:00 2001 From: Tyler Dane Date: Sat, 7 Mar 2026 19:52:04 -0800 Subject: [PATCH 17/24] fix(sync): enhance Google Calendar sync handling and notification processing - Updated SyncController to skip recovery if a full import is already active for the user. - Refactored syncService to ignore stale notifications when no exact watch record exists, improving notification handling. - Enhanced tests to validate the new behavior for handling Google Calendar notifications and sync states. - Improved user metadata service to return appropriate sync statuses without scheduling unnecessary repairs. --- .../sync/controllers/sync.controller.test.ts | 81 ++++++++++++++--- .../src/sync/controllers/sync.controller.ts | 15 +++ .../src/sync/services/sync.service.test.ts | 23 ++--- .../backend/src/sync/services/sync.service.ts | 91 +++++-------------- .../services/user-metadata.service.test.ts | 13 ++- .../user/services/user-metadata.service.ts | 58 +----------- 6 files changed, 128 insertions(+), 153 deletions(-) diff --git a/packages/backend/src/sync/controllers/sync.controller.test.ts b/packages/backend/src/sync/controllers/sync.controller.test.ts index 25aefdf37..e270bc0bd 100644 --- a/packages/backend/src/sync/controllers/sync.controller.test.ts +++ b/packages/backend/src/sync/controllers/sync.controller.test.ts @@ -33,6 +33,7 @@ import { missingRefreshTokenError } from "@backend/__tests__/mocks.gcal/errors/e 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 { GCalNotificationHandler } from "@backend/sync/services/notify/handler/gcal.notification.handler"; 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"; @@ -236,7 +237,12 @@ describe("SyncController", () => { const userId = user._id.toString(); const restartSpy = jest .spyOn(userService, "restartGoogleCalendarSync") - .mockResolvedValue(); + .mockImplementation(async () => { + await userMetadataService.updateUserMetadata({ + userId, + data: { sync: { importGCal: "importing" } }, + }); + }); const watch = await mongoService.watch.findOne({ user: userId, @@ -246,11 +252,6 @@ describe("SyncController", () => { expect(watch).toBeDefined(); expect(watch).not.toBeNull(); - await userMetadataService.updateUserMetadata({ - userId, - data: { sync: { importGCal: "importing" } }, - }); - await updateSync(Resource_Sync.EVENTS, userId, watch!.gCalendarId, { nextSyncToken: undefined, }); @@ -277,14 +278,13 @@ describe("SyncController", () => { Status.NO_CONTENT, ); - expect(restartSpy).toHaveBeenCalledTimes(2); - expect(restartSpy).toHaveBeenNthCalledWith(1, userId, { force: true }); - expect(restartSpy).toHaveBeenNthCalledWith(2, userId, { force: true }); + expect(restartSpy).toHaveBeenCalledTimes(1); + expect(restartSpy).toHaveBeenCalledWith(userId, { force: true }); restartSpy.mockRestore(); }); - it("should cleanup stale gcal watches for unknown channels if resourceId exists", async () => { + it("should ignore stale notifications when only resourceId matches", async () => { const { user } = await UtilDriver.setupTestUser(); const userId = user._id.toString(); const calendarId = "test-calendar"; @@ -312,7 +312,66 @@ describe("SyncController", () => { expect(response.text).toEqual("IGNORED"); expect( await mongoService.watch.findOne({ user: userId, resourceId }), - ).toBeNull(); + ).toEqual(expect.objectContaining({ user: userId, resourceId })); + }); + + it("does not trigger a repair import for a late stale notification after a processed change", 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 notificationSpy = jest + .spyOn(GCalNotificationHandler.prototype, "handleNotification") + .mockResolvedValue({ summary: "PROCESSED", changes: [] }); + const backgroundChangeSpy = jest.spyOn( + webSocketServer, + "handleBackgroundCalendarChange", + ); + const importStartSpy = jest.spyOn( + webSocketServer, + "handleImportGCalStart", + ); + + const activeResponse = await syncDriver.handleGoogleNotification( + { + resource: Resource_Sync.EVENTS, + channelId: watch!._id, + resourceId: watch!.resourceId, + resourceState: XGoogleResourceState.EXISTS, + expiration: watch!.expiration, + }, + Status.OK, + ); + + const staleResponse = await syncDriver.handleGoogleNotification( + { + resource: Resource_Sync.EVENTS, + channelId: new ObjectId(), + resourceId: watch!.resourceId, + resourceState: XGoogleResourceState.EXISTS, + expiration: watch!.expiration, + }, + Status.OK, + ); + + expect(activeResponse.text).toContain("PROCESSED"); + expect(staleResponse.text).toEqual("IGNORED"); + expect(notificationSpy).toHaveBeenCalledTimes(1); + expect(backgroundChangeSpy).toHaveBeenCalledTimes(1); + expect(importStartSpy).not.toHaveBeenCalled(); + expect( + await mongoService.watch.findOne({ _id: watch!._id, user: userId }), + ).toEqual(expect.objectContaining({ user: userId })); + + notificationSpy.mockRestore(); + backgroundChangeSpy.mockRestore(); + importStartSpy.mockRestore(); }); it("should prune Google data, notify client via websocket, and return structured response when user revokes access", async () => { diff --git a/packages/backend/src/sync/controllers/sync.controller.ts b/packages/backend/src/sync/controllers/sync.controller.ts index 05460b1e7..81684e931 100644 --- a/packages/backend/src/sync/controllers/sync.controller.ts +++ b/packages/backend/src/sync/controllers/sync.controller.ts @@ -113,6 +113,21 @@ export class SyncController { resourceId, }); + const metadata = await userMetadataService.fetchUserMetadata( + userId, + undefined, + { skipAssessment: true }, + ); + const importStatus = metadata.sync?.importGCal; + + if (importStatus === "importing" || importStatus === "restart") { + logger.info( + `Skipped Google sync recovery because full import is already active for user: ${userId}`, + ); + res.status(Status.NO_CONTENT).send(); + return; + } + // Force-restart sync to recover from invalid sync token. // When Google returns 410 (sync token invalid), the token may still exist // in the database but is no longer valid. assessGoogleMetadata checks token diff --git a/packages/backend/src/sync/services/sync.service.test.ts b/packages/backend/src/sync/services/sync.service.test.ts index 06745dcd1..5e91f6218 100644 --- a/packages/backend/src/sync/services/sync.service.test.ts +++ b/packages/backend/src/sync/services/sync.service.test.ts @@ -14,7 +14,6 @@ import { missingRefreshTokenError } from "@backend/__tests__/mocks.gcal/errors/e 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"; -import userMetadataService from "@backend/user/services/user-metadata.service"; const createWatch = async (user: string) => { const watch = WatchSchema.parse({ @@ -192,7 +191,7 @@ describe("SyncService", () => { await expect( syncService.handleGcalNotification({ resource: Resource_Sync.EVENTS, - channelId: new ObjectId().toString(), + channelId: new ObjectId(), resourceId: faker.string.uuid(), resourceState: XGoogleResourceState.EXISTS, expiration: faker.date.future(), @@ -204,27 +203,25 @@ describe("SyncService", () => { }); describe("cleanupStaleWatchChannel", () => { - it("returns deletion result even when metadata assessment fails", async () => { + it("ignores stale notifications when no exact watch record exists", async () => { const user = await UserDriver.createUser(); const watch = await createWatch(user._id.toString()); - - jest.spyOn(syncService, "stopWatch").mockResolvedValue({ - channelId: watch._id.toString(), - resourceId: watch.resourceId, - }); - jest - .spyOn(userMetadataService, "assessGoogleMetadata") - .mockRejectedValue(new Error("metadata assessment failed")); + const stopWatchSpy = jest.spyOn(syncService, "stopWatch"); await expect( syncService.cleanupStaleWatchChannel({ resource: Resource_Sync.EVENTS, - channelId: watch._id.toString(), + channelId: new ObjectId(), resourceId: watch.resourceId, resourceState: XGoogleResourceState.EXISTS, expiration: faker.date.future(), }), - ).resolves.toBe(true); + ).resolves.toBe(false); + + expect(stopWatchSpy).not.toHaveBeenCalled(); + expect(await mongoService.watch.findOne({ _id: watch._id })).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 92a99f880..b0511e4e3 100644 --- a/packages/backend/src/sync/services/sync.service.ts +++ b/packages/backend/src/sync/services/sync.service.ts @@ -8,9 +8,10 @@ import { type Payload_Sync_Notif, Resource_Sync, type Result_Watch_Stop, + XGoogleResourceState, } from "@core/types/sync.types"; import { ExpirationDateSchema } from "@core/types/type.utils"; -import { type Schema_Watch, WatchSchema } from "@core/types/watch.types"; +import { WatchSchema } from "@core/types/watch.types"; import { shouldDoIncrementalGCalSync } from "@core/util/event/event.util"; import { getGcalClient } from "@backend/auth/services/google/google.auth.service"; import { MONGO_BATCH_SIZE } from "@backend/common/constants/backend.constants"; @@ -38,10 +39,7 @@ import { isWatchingGoogleResource, updateSync, } from "@backend/sync/util/sync.queries"; -import { - getChannelExpiration, - isUsingHttps, -} from "@backend/sync/util/sync.util"; +import { getChannelExpiration } from "@backend/sync/util/sync.util"; import { findCompassUserBy } from "@backend/user/queries/user.queries"; import userMetadataService from "@backend/user/services/user-metadata.service"; @@ -94,91 +92,48 @@ class SyncService { channelId, resourceId, }: Payload_Sync_Notif): Promise { - const channels: Schema_Watch[] = []; - const channel = await mongoService.watch.findOne({ _id: channelId, resourceId, }); - if (channel) channels.push(channel); - if (!channel) { logger.warn( - `Exact match not found for stale watch record cleanup: { channelId: ${channelId}, resourceId: ${resourceId}}. Extending Search using resourceId only.`, + `Ignoring stale Google notification because no exact watch exists for channelId: ${channelId.toString()}, resourceId: ${resourceId}`, ); - const resourceMatchedChannels = await mongoService.watch - .find({ resourceId }) - .toArray(); + return false; + } - if (resourceMatchedChannels.length > 0) { - logger.warn( - `Found ${resourceMatchedChannels.length} watch records with resourceId: ${resourceId}.`, - ); + try { + await this.stopWatch( + channel.user, + channel._id.toString(), + channel.resourceId, + ); - channels.push(...resourceMatchedChannels); - } - } + logger.warn( + `Cleaned up stale watch for user: ${channel.user} with channelId: ${channel._id.toString()} with resourceId: ${channel.resourceId}`, + ); - if (channels.length === 0) { + return true; + } catch (error) { logger.error( - `Stale watch cleanup failed. Couldn't find any watch based on this channelId: ${channelId} or resourceId: ${resourceId}`, + `Failed to clean up stale watch for user: ${channel.user} with channelId: ${channel._id.toString()}`, + error, ); return false; } - - const deleted = await Promise.all( - channels.map(async (channel): Promise => { - try { - await this.stopWatch( - channel.user, - channel._id.toString(), - channel.resourceId, - ); - - logger.warn( - `Cleaned up stale watch for user: ${channel.user} with channelId: ${channel._id.toString()} with resourceId: ${channel.resourceId}`, - ); - - return true; - } catch (error) { - logger.error( - `Failed to clean up stale watch for user: ${channel.user} with channelId: ${channel._id.toString()}`, - error, - ); - - return false; - } - }), - ); - - const affectedUsers = [...new Set(channels.map(({ user }) => user))]; - - await Promise.all( - affectedUsers.map(async (userId) => { - try { - await userMetadataService.assessGoogleMetadata(userId); - } catch (error) { - logger.error( - `Failed to assess Google metadata after stale watch cleanup for user: ${userId}`, - error, - ); - } - }), - ); - - return deleted.some((d) => d); } handleGcalNotification = async (payload: Payload_Sync_Notif) => { const { channelId, resourceId, resourceState, resource } = payload; const { expiration } = payload; - if (resourceState === "sync") { + if (resourceState === XGoogleResourceState.SYNC) { logger.info( - `${resource} sync initialized for channelId: ${payload.channelId}`, + `${resource} sync initialized for channelId: ${payload.channelId.toString()}`, ); return "INITIALIZED"; @@ -197,7 +152,7 @@ class SyncService { if (cleanedUp) return "IGNORED"; logger.warn( - `Ignoring notification because no active watch record exists for channel: ${payload.channelId}`, + `Ignoring notification because no active watch record exists for channel: ${payload.channelId.toString()}`, ); return "IGNORED"; @@ -212,7 +167,7 @@ class SyncService { if (cleanedUp) return "IGNORED"; logger.warn( - `Ignoring notification because no sync record exists for channel: ${payload.channelId}`, + `Ignoring notification because no sync record exists for channel: ${payload.channelId.toString()}`, ); return "IGNORED"; diff --git a/packages/backend/src/user/services/user-metadata.service.test.ts b/packages/backend/src/user/services/user-metadata.service.test.ts index a09448582..d8d81fe11 100644 --- a/packages/backend/src/user/services/user-metadata.service.test.ts +++ b/packages/backend/src/user/services/user-metadata.service.test.ts @@ -102,7 +102,8 @@ describe("UserMetadataService", () => { it("returns healthy without active watches when running without https", async () => { const { user } = await UtilDriver.setupTestUser(); const userId = user._id.toString(); - (isUsingHttps as jest.Mock).mockReturnValueOnce(false); + const isUsingHttpsSpy = isUsingHttps as jest.Mock; + isUsingHttpsSpy.mockReturnValue(false); await WatchDriver.deleteManyByUser(userId); @@ -113,9 +114,11 @@ describe("UserMetadataService", () => { connectionStatus: "connected", syncStatus: "healthy", }); + + isUsingHttpsSpy.mockRestore(); }); - it("schedules repair and returns repairing when connected sync state is broken", async () => { + it("returns attention without scheduling repair when connected sync state is broken", async () => { const user = await UserDriver.createUser(); const userId = user._id.toString(); const restartSpy = jest @@ -127,9 +130,9 @@ describe("UserMetadataService", () => { expect(metadata.google).toMatchObject({ hasRefreshToken: true, connectionStatus: "connected", - syncStatus: "repairing", + syncStatus: "attention", }); - expect(restartSpy).toHaveBeenCalledWith(userId, { force: true }); + expect(restartSpy).not.toHaveBeenCalled(); restartSpy.mockRestore(); }); @@ -152,7 +155,7 @@ describe("UserMetadataService", () => { }); }); - it("does not schedule duplicate repairs when an import is already running", async () => { + it("returns repairing while an import is already running without scheduling a repair", async () => { const user = await UserDriver.createUser(); const userId = user._id.toString(); const restartSpy = jest diff --git a/packages/backend/src/user/services/user-metadata.service.ts b/packages/backend/src/user/services/user-metadata.service.ts index 9ce283564..ffe03b651 100644 --- a/packages/backend/src/user/services/user-metadata.service.ts +++ b/packages/backend/src/user/services/user-metadata.service.ts @@ -2,7 +2,6 @@ import mergeWith from "lodash.mergewith"; import SupertokensUserMetadata, { type JSONObject, } from "supertokens-node/recipe/usermetadata"; -import { Logger } from "@core/logger/winston.logger"; import { Resource_Sync } from "@core/types/sync.types"; import { type GoogleConnectionStatus, @@ -16,18 +15,13 @@ import { getSync } from "@backend/sync/util/sync.queries"; import { isUsingHttps } from "@backend/sync/util/sync.util"; import { findCompassUserBy } from "@backend/user/queries/user.queries"; -const logger = Logger("app:user.metadata.service"); - type GoogleMetadataAssessment = { hasRefreshToken: boolean; connectionStatus: GoogleConnectionStatus; syncStatus: GoogleSyncStatus; - scheduledRepair: boolean; }; class UserMetadataService { - private readonly pendingGoogleRepairs = new Map>(); - private getStoredUserMetadata = async ( userId: string, userContext?: Record, @@ -54,10 +48,6 @@ class UserMetadataService { return "connected"; } - private hasPendingGoogleRepair(userId: string): boolean { - return this.pendingGoogleRepairs.has(userId); - } - private async isGoogleSyncHealthy(userId: string): Promise { const sync = await getSync({ userId }); @@ -99,30 +89,6 @@ class UserMetadataService { ); } - private scheduleGoogleRepair(userId: string): boolean { - if (this.hasPendingGoogleRepair(userId)) { - return false; - } - - const repair = import("@backend/user/services/user.service") - .then(({ default: userService }) => - userService.restartGoogleCalendarSync(userId, { force: true }), - ) - .catch((err) => { - logger.error( - `Failed to schedule Google Calendar repair for user: ${userId}`, - err, - ); - }) - .finally(() => { - this.pendingGoogleRepairs.delete(userId); - }); - - this.pendingGoogleRepairs.set(userId, repair); - - return true; - } - assessGoogleMetadata = async ( userId: string, metadata?: UserMetadata, @@ -138,22 +104,16 @@ class UserMetadataService { hasRefreshToken, connectionStatus, syncStatus: "none", - scheduledRepair: false, }; } const importStatus = storedMetadata.sync?.importGCal; - if ( - importStatus === "importing" || - importStatus === "restart" || - this.hasPendingGoogleRepair(userId) - ) { + if (importStatus === "importing" || importStatus === "restart") { return { hasRefreshToken, connectionStatus, syncStatus: "repairing", - scheduledRepair: false, }; } @@ -164,7 +124,6 @@ class UserMetadataService { hasRefreshToken, connectionStatus, syncStatus: "healthy", - scheduledRepair: false, }; } @@ -173,26 +132,13 @@ class UserMetadataService { hasRefreshToken, connectionStatus, syncStatus: "attention", - scheduledRepair: false, }; } - if (this.hasPendingGoogleRepair(userId)) { - return { - hasRefreshToken, - connectionStatus, - syncStatus: "repairing", - scheduledRepair: false, - }; - } - - const scheduledRepair = this.scheduleGoogleRepair(userId); - return { hasRefreshToken, connectionStatus, - syncStatus: scheduledRepair ? "repairing" : "attention", - scheduledRepair, + syncStatus: "attention", }; }; From 7c05304b9845c5a31618dca14a130bd866d8c2d1 Mon Sep 17 00:00:00 2001 From: Tyler Dane Date: Sat, 7 Mar 2026 19:54:20 -0800 Subject: [PATCH 18/24] fix(user-metadata): refactor user metadata retrieval and update logic - Introduced a new type for the response structure from SupertokensUserMetadata, improving type safety. - Updated the getStoredUserMetadata and updateUserMetadata methods to handle the new response format, ensuring proper error handling and returning the correct metadata. - Enhanced code readability by simplifying the response handling process. --- .../user/services/user-metadata.service.ts | 27 ++++++++++++------- 1 file changed, 18 insertions(+), 9 deletions(-) diff --git a/packages/backend/src/user/services/user-metadata.service.ts b/packages/backend/src/user/services/user-metadata.service.ts index ffe03b651..637662a62 100644 --- a/packages/backend/src/user/services/user-metadata.service.ts +++ b/packages/backend/src/user/services/user-metadata.service.ts @@ -21,19 +21,25 @@ type GoogleMetadataAssessment = { syncStatus: GoogleSyncStatus; }; +type GetUserMetadataResponse = { + status: string; + metadata: UserMetadata; +}; + class UserMetadataService { private getStoredUserMetadata = async ( userId: string, userContext?: Record, ): Promise => { - const { status, metadata } = await SupertokensUserMetadata.getUserMetadata( + const result = (await SupertokensUserMetadata.getUserMetadata( userId, userContext, - ); + )) as GetUserMetadataResponse; - if (status !== "OK") throw new Error("Failed to fetch user metadata"); + if (result.status !== "OK") + throw new Error("Failed to fetch user metadata"); - return metadata as UserMetadata; + return result.metadata; }; private getGoogleConnectionStatus( @@ -158,14 +164,17 @@ class UserMetadataService { data: Partial; }): Promise => { const value = await this.getStoredUserMetadata(userId); - const update = mergeWith(value, data); + const update = mergeWith(value, data) as UserMetadata; - const { status, metadata } = - await SupertokensUserMetadata.updateUserMetadata(userId, update); + const result = (await SupertokensUserMetadata.updateUserMetadata( + userId, + update, + )) as GetUserMetadataResponse; - if (status !== "OK") throw new Error("Failed to update user metadata"); + if (result.status !== "OK") + throw new Error("Failed to update user metadata"); - return metadata; + return result.metadata; }; fetchUserMetadata = async ( From 03abffa0960f6684d4be756d5151a2f33b87780a Mon Sep 17 00:00:00 2001 From: Tyler Dane Date: Sat, 7 Mar 2026 21:11:29 -0800 Subject: [PATCH 19/24] feat(user-metadata): implement user metadata refresh and loading state management - Introduced a new utility function, `refreshUserMetadata`, to handle user metadata retrieval and state updates in the Redux store. - Enhanced the `userMetadataSlice` to include loading states, allowing for better user experience during metadata fetching. - Updated the `SessionProvider` to refresh user metadata upon session initialization and when a session already exists. - Refactored the `useConnectGoogle` hook to incorporate user metadata status, improving the handling of Google Calendar connection states. - Added tests for the new user metadata utility and updated existing tests to ensure proper functionality and state management. --- .../__tests__/utils/state/store.test.util.ts | 6 +- .../auth/hooks/oauth/useConnectGoogle.test.ts | 104 ++++++++++++++++- .../src/auth/hooks/oauth/useConnectGoogle.ts | 49 +++++++- .../auth/hooks/oauth/useGoogleAuth.test.ts | 13 ++- .../web/src/auth/hooks/oauth/useGoogleAuth.ts | 2 + .../src/auth/session/SessionProvider.test.tsx | 110 ++++++++++++++++++ .../web/src/auth/session/SessionProvider.tsx | 5 + .../auth/session/user-metadata.util.test.ts | 88 ++++++++++++++ .../src/auth/session/user-metadata.util.ts | 38 ++++++ .../auth/selectors/user-metadata.selectors.ts | 3 + .../ducks/auth/slices/user-metadata.slice.ts | 39 ++++++- 11 files changed, 445 insertions(+), 12 deletions(-) create mode 100644 packages/web/src/auth/session/SessionProvider.test.tsx create mode 100644 packages/web/src/auth/session/user-metadata.util.test.ts create mode 100644 packages/web/src/auth/session/user-metadata.util.ts 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 2751d6e53..fd9776706 100644 --- a/packages/web/src/__tests__/utils/state/store.test.util.ts +++ b/packages/web/src/__tests__/utils/state/store.test.util.ts @@ -117,6 +117,10 @@ export const createInitialState = ( settings: { isCmdPaletteOpen: false, }, + userMetadata: { + current: null, + status: "idle", + }, sync: { importGCal: { importing: false, @@ -131,7 +135,7 @@ export const createInitialState = ( }, }, ...partialState, - }; + } as InitialReduxState; }; export const createStoreWithEvents = ( diff --git a/packages/web/src/auth/hooks/oauth/useConnectGoogle.test.ts b/packages/web/src/auth/hooks/oauth/useConnectGoogle.test.ts index cce4697db..61522ad30 100644 --- a/packages/web/src/auth/hooks/oauth/useConnectGoogle.test.ts +++ b/packages/web/src/auth/hooks/oauth/useConnectGoogle.test.ts @@ -1,14 +1,19 @@ import { renderHook } from "@testing-library/react"; import { useConnectGoogle } from "@web/auth/hooks/oauth/useConnectGoogle"; import { useGoogleAuth } from "@web/auth/hooks/oauth/useGoogleAuth"; +import { hasUserEverAuthenticated } from "@web/auth/state/auth.state.util"; import { SyncApi } from "@web/common/apis/sync.api"; -import { selectGoogleMetadata } from "@web/ducks/auth/selectors/user-metadata.selectors"; +import { + selectGoogleMetadata, + selectUserMetadataStatus, +} from "@web/ducks/auth/selectors/user-metadata.selectors"; import { selectImportGCalState } from "@web/ducks/events/selectors/sync.selector"; import { importGCalSlice } from "@web/ducks/events/slices/sync.slice"; import { settingsSlice } from "@web/ducks/settings/slices/settings.slice"; import { useAppDispatch, useAppSelector } from "@web/store/store.hooks"; jest.mock("@web/auth/hooks/oauth/useGoogleAuth"); +jest.mock("@web/auth/state/auth.state.util"); jest.mock("@web/common/apis/sync.api"); jest.mock("@web/store/store.hooks"); @@ -21,6 +26,10 @@ const mockUseAppDispatch = useAppDispatch as jest.MockedFunction< const mockUseAppSelector = useAppSelector as jest.MockedFunction< typeof useAppSelector >; +const mockHasUserEverAuthenticated = + hasUserEverAuthenticated as jest.MockedFunction< + typeof hasUserEverAuthenticated + >; const mockSyncApi = SyncApi as jest.Mocked; describe("useConnectGoogle", () => { @@ -35,12 +44,17 @@ describe("useConnectGoogle", () => { data: null, loading: false, }); + mockHasUserEverAuthenticated.mockReturnValue(true); mockSyncApi.importGCal.mockResolvedValue(undefined as never); mockUseAppSelector.mockImplementation((selector) => { if (selector === selectGoogleMetadata) { return undefined; } + if (selector === selectUserMetadataStatus) { + return "loading"; + } + if (selector === selectImportGCalState) { return { importing: false, @@ -52,7 +66,42 @@ describe("useConnectGoogle", () => { }); }); - it("returns connect state when metadata is missing", () => { + it("returns checking state when metadata is still loading", () => { + const { result } = renderHook(() => useConnectGoogle()); + + expect(result.current.commandAction.label).toBe( + "Checking Google Calendar…", + ); + expect(result.current.commandAction.isDisabled).toBe(true); + expect(result.current.sidebarStatus.icon).toBe("SpinnerIcon"); + expect(result.current.sidebarStatus.tooltip).toBe( + "Checking Google Calendar status…", + ); + }); + + it("returns connect state when metadata is loaded and Google is not connected", () => { + mockUseAppSelector.mockImplementation((selector) => { + if (selector === selectGoogleMetadata) { + return { + connectionStatus: "not_connected", + syncStatus: "none", + }; + } + + if (selector === selectUserMetadataStatus) { + return "loaded"; + } + + if (selector === selectImportGCalState) { + return { + importing: false, + isImportPending: false, + }; + } + + return undefined; + }); + const { result } = renderHook(() => useConnectGoogle()); expect(result.current.commandAction.label).toBe("Connect Google Calendar"); @@ -72,6 +121,10 @@ describe("useConnectGoogle", () => { }; } + if (selector === selectUserMetadataStatus) { + return "loaded"; + } + if (selector === selectImportGCalState) { return { importing: false, @@ -102,6 +155,10 @@ describe("useConnectGoogle", () => { }; } + if (selector === selectUserMetadataStatus) { + return "loaded"; + } + if (selector === selectImportGCalState) { return { importing: false, @@ -135,6 +192,10 @@ describe("useConnectGoogle", () => { }; } + if (selector === selectUserMetadataStatus) { + return "loaded"; + } + if (selector === selectImportGCalState) { return { importing: false, @@ -163,6 +224,10 @@ describe("useConnectGoogle", () => { }; } + if (selector === selectUserMetadataStatus) { + return "loaded"; + } + if (selector === selectImportGCalState) { return { importing: false, @@ -217,6 +282,10 @@ describe("useConnectGoogle", () => { }; } + if (selector === selectUserMetadataStatus) { + return "loaded"; + } + if (selector === selectImportGCalState) { return { importing: true, @@ -243,6 +312,10 @@ describe("useConnectGoogle", () => { }; } + if (selector === selectUserMetadataStatus) { + return "loaded"; + } + if (selector === selectImportGCalState) { return { importing: true, @@ -261,4 +334,31 @@ describe("useConnectGoogle", () => { expect(result.current.sidebarStatus.icon).toBe("LinkBreakIcon"); expect(result.current.commandAction.isDisabled).toBe(false); }); + + it("returns connect state when metadata is missing for a never-authenticated user", () => { + mockHasUserEverAuthenticated.mockReturnValue(false); + mockUseAppSelector.mockImplementation((selector) => { + if (selector === selectGoogleMetadata) { + return undefined; + } + + if (selector === selectUserMetadataStatus) { + return "idle"; + } + + if (selector === selectImportGCalState) { + return { + importing: false, + isImportPending: false, + }; + } + + return undefined; + }); + + const { result } = renderHook(() => useConnectGoogle()); + + expect(result.current.commandAction.label).toBe("Connect Google Calendar"); + expect(result.current.sidebarStatus.icon).toBe("CloudArrowUpIcon"); + }); }); diff --git a/packages/web/src/auth/hooks/oauth/useConnectGoogle.ts b/packages/web/src/auth/hooks/oauth/useConnectGoogle.ts index 161022109..00b3b58fc 100644 --- a/packages/web/src/auth/hooks/oauth/useConnectGoogle.ts +++ b/packages/web/src/auth/hooks/oauth/useConnectGoogle.ts @@ -2,16 +2,24 @@ import { useCallback } from "react"; import { type GoogleConnectionStatus, type GoogleSyncStatus, + type UserMetadata, } from "@core/types/user.types"; import { useGoogleAuth } from "@web/auth/hooks/oauth/useGoogleAuth"; +import { hasUserEverAuthenticated } from "@web/auth/state/auth.state.util"; import { SyncApi } from "@web/common/apis/sync.api"; -import { selectGoogleMetadata } from "@web/ducks/auth/selectors/user-metadata.selectors"; +import { + selectGoogleMetadata, + selectUserMetadataStatus, +} from "@web/ducks/auth/selectors/user-metadata.selectors"; +import type { UserMetadataStatus } from "@web/ducks/auth/slices/user-metadata.slice"; import { selectImportGCalState } from "@web/ducks/events/selectors/sync.selector"; import { importGCalSlice } from "@web/ducks/events/slices/sync.slice"; import { settingsSlice } from "@web/ducks/settings/slices/settings.slice"; +import type { RootState } from "@web/store"; import { useAppDispatch, useAppSelector } from "@web/store/store.hooks"; type GoogleUiState = + | "checking" | "not_connected" | "reconnect_required" | "connected_healthy" @@ -48,10 +56,12 @@ const getGoogleUiState = ({ connectionStatus, syncStatus, isImporting, + isCheckingStatus, }: { connectionStatus: GoogleConnectionStatus; syncStatus: GoogleSyncStatus; isImporting: boolean; + isCheckingStatus: boolean; }): GoogleUiState => { if (connectionStatus === "reconnect_required") { return "reconnect_required"; @@ -61,6 +71,10 @@ const getGoogleUiState = ({ return "connected_repairing"; } + if (isCheckingStatus) { + return "checking"; + } + if (connectionStatus === "connected" && syncStatus === "repairing") { return "connected_repairing"; } @@ -83,6 +97,19 @@ const getGoogleUiConfig = ( onRepairGoogleFromSidebar: () => void, ): GoogleUiConfig => { switch (state) { + case "checking": + return { + commandAction: { + label: "Checking Google Calendar…", + icon: COMMAND_ICON, + isDisabled: true, + }, + sidebarStatus: { + icon: "SpinnerIcon", + tooltip: "Checking Google Calendar status…", + isDisabled: true, + }, + }; case "not_connected": return { commandAction: { @@ -159,8 +186,19 @@ const getGoogleUiConfig = ( export const useConnectGoogle = () => { const dispatch = useAppDispatch(); - const googleMetadata = useAppSelector(selectGoogleMetadata); - const importGCal = useAppSelector(selectImportGCalState); + const googleMetadata = useAppSelector( + selectGoogleMetadata as ( + state: RootState, + ) => UserMetadata["google"] | undefined, + ); + const userMetadataStatus = useAppSelector( + selectUserMetadataStatus as (state: RootState) => UserMetadataStatus, + ); + const importGCal = useAppSelector( + selectImportGCalState as ( + state: RootState, + ) => RootState["sync"]["importGCal"], + ); const { login } = useGoogleAuth(); const onOpenGoogleAuth = useCallback(() => { @@ -201,10 +239,15 @@ export const useConnectGoogle = () => { const connectionStatus = googleMetadata?.connectionStatus ?? "not_connected"; const syncStatus = googleMetadata?.syncStatus ?? "none"; + const isCheckingStatus = + !googleMetadata && + userMetadataStatus !== "loaded" && + hasUserEverAuthenticated(); const state = getGoogleUiState({ connectionStatus, syncStatus, isImporting: importGCal.importing || importGCal.isImportPending, + isCheckingStatus, }); return getGoogleUiConfig( diff --git a/packages/web/src/auth/hooks/oauth/useGoogleAuth.test.ts b/packages/web/src/auth/hooks/oauth/useGoogleAuth.test.ts index 8a6e46132..b0c9a24ff 100644 --- a/packages/web/src/auth/hooks/oauth/useGoogleAuth.test.ts +++ b/packages/web/src/auth/hooks/oauth/useGoogleAuth.test.ts @@ -5,6 +5,7 @@ import { } from "@web/auth/google/google.auth.util"; import { useGoogleAuth } from "@web/auth/hooks/oauth/useGoogleAuth"; import { useSession } from "@web/auth/hooks/session/useSession"; +import { refreshUserMetadata } from "@web/auth/session/user-metadata.util"; import { markUserAsAuthenticated } from "@web/auth/state/auth.state.util"; import { useGoogleLogin } from "@web/components/oauth/google/useGoogleLogin"; import { type SignInUpInput } from "@web/components/oauth/ouath.types"; @@ -12,6 +13,7 @@ import { type SignInUpInput } from "@web/components/oauth/ouath.types"; // Mock dependencies jest.mock("@web/auth/google/google.auth.util"); jest.mock("@web/auth/hooks/session/useSession"); +jest.mock("@web/auth/session/user-metadata.util"); jest.mock("@web/components/oauth/google/useGoogleLogin"); jest.mock("@web/auth/state/auth.state.util"); jest.mock("@web/store/store.hooks", () => ({ @@ -38,8 +40,13 @@ const mockUseSession = useSession as jest.MockedFunction; const mockUseGoogleLogin = useGoogleLogin as jest.MockedFunction< typeof useGoogleLogin >; -const mockUseAppDispatch = jest.requireMock("@web/store/store.hooks") - .useAppDispatch as jest.Mock; +const mockRefreshUserMetadata = refreshUserMetadata as jest.MockedFunction< + typeof refreshUserMetadata +>; +const storeHooksMock = jest.requireMock("@web/store/store.hooks") as { + useAppDispatch: jest.Mock; +}; +const mockUseAppDispatch = storeHooksMock.useAppDispatch; const mockMarkUserAsAuthenticated = markUserAsAuthenticated as jest.MockedFunction< typeof markUserAsAuthenticated @@ -64,6 +71,7 @@ describe("useGoogleAuth", () => { }); mockAuthenticate.mockResolvedValue({ success: true }); mockSyncLocalEvents.mockResolvedValue({ syncedCount: 0, success: true }); + mockRefreshUserMetadata.mockResolvedValue(); }); afterEach(() => { @@ -141,6 +149,7 @@ describe("useGoogleAuth", () => { expect(mockMarkUserAsAuthenticated).toHaveBeenCalled(); expect(mockSetAuthenticated).toHaveBeenCalledWith(true); + expect(mockRefreshUserMetadata).toHaveBeenCalledTimes(1); }); describe("onStart callback", () => { diff --git a/packages/web/src/auth/hooks/oauth/useGoogleAuth.ts b/packages/web/src/auth/hooks/oauth/useGoogleAuth.ts index 5eec6442c..a5fcc3684 100644 --- a/packages/web/src/auth/hooks/oauth/useGoogleAuth.ts +++ b/packages/web/src/auth/hooks/oauth/useGoogleAuth.ts @@ -7,6 +7,7 @@ import { } from "@web/auth/google/google.auth.util"; import { useGoogleAuthWithOverlay } from "@web/auth/hooks/oauth/useGoogleAuthWithOverlay"; import { useSession } from "@web/auth/hooks/session/useSession"; +import { refreshUserMetadata } from "@web/auth/session/user-metadata.util"; import { markUserAsAuthenticated } from "@web/auth/state/auth.state.util"; import { toastDefaultOptions } from "@web/common/constants/toast.constants"; import { @@ -54,6 +55,7 @@ export function useGoogleAuth() { markUserAsAuthenticated(); setAuthenticated(true); + void refreshUserMetadata(); // Batch these dispatches to ensure they update in the same render cycle, // preventing a flash where isAuthenticating=false but importing=false diff --git a/packages/web/src/auth/session/SessionProvider.test.tsx b/packages/web/src/auth/session/SessionProvider.test.tsx new file mode 100644 index 000000000..f26b18c5f --- /dev/null +++ b/packages/web/src/auth/session/SessionProvider.test.tsx @@ -0,0 +1,110 @@ +import { act } from "react"; +import { waitFor } from "@testing-library/react"; +import { userMetadataSlice } from "@web/ducks/auth/slices/user-metadata.slice"; + +describe("SessionProvider sessionInit", () => { + beforeEach(() => { + jest.resetModules(); + }); + + it("refreshes user metadata when a session already exists", async () => { + const refreshUserMetadata = jest.fn().mockResolvedValue(undefined); + const reconnect = jest.fn(); + const connect = jest.fn(); + const disconnect = jest.fn(); + const dispatch = jest.fn(); + const markUserAsAuthenticated = jest.fn(); + + jest.doMock("@web/auth/session/user-metadata.util", () => ({ + refreshUserMetadata, + })); + jest.doMock("@web/socket/provider/SocketProvider", () => ({ + socket: { + connected: false, + connect, + disconnect, + }, + reconnect, + disconnect, + })); + jest.doMock("@web/store", () => ({ + store: { + dispatch, + }, + })); + jest.doMock("@web/auth/state/auth.state.util", () => ({ + markUserAsAuthenticated, + })); + + await jest.isolateModulesAsync(async () => { + const { session } = await import("@web/common/classes/Session"); + const { sessionInit } = await import("./SessionProvider"); + + (session.doesSessionExist as jest.Mock).mockResolvedValue(true); + + sessionInit(); + + await waitFor(() => { + expect(markUserAsAuthenticated).toHaveBeenCalledTimes(1); + expect(refreshUserMetadata).toHaveBeenCalledTimes(1); + }); + expect(connect).toHaveBeenCalledTimes(1); + }); + }); + + it("refreshes metadata on session creation and clears it on sign out", async () => { + const refreshUserMetadata = jest.fn().mockResolvedValue(undefined); + const reconnect = jest.fn(); + const connect = jest.fn(); + const disconnect = jest.fn(); + const dispatch = jest.fn(); + const markUserAsAuthenticated = jest.fn(); + + jest.doMock("@web/auth/session/user-metadata.util", () => ({ + refreshUserMetadata, + })); + jest.doMock("@web/socket/provider/SocketProvider", () => ({ + socket: { + connected: true, + connect, + disconnect, + }, + reconnect, + disconnect, + })); + jest.doMock("@web/store", () => ({ + store: { + dispatch, + }, + })); + jest.doMock("@web/auth/state/auth.state.util", () => ({ + markUserAsAuthenticated, + })); + + await jest.isolateModulesAsync(async () => { + const { session } = await import("@web/common/classes/Session"); + const { sessionInit } = await import("./SessionProvider"); + + (session.doesSessionExist as jest.Mock).mockResolvedValue(false); + + sessionInit(); + + await act(async () => { + session.emit("SESSION_CREATED", { action: "SESSION_CREATED" } as never); + }); + + await waitFor(() => { + expect(markUserAsAuthenticated).toHaveBeenCalledTimes(1); + expect(refreshUserMetadata).toHaveBeenCalledTimes(1); + }); + expect(reconnect).toHaveBeenCalledTimes(1); + + await act(async () => { + session.emit("SIGN_OUT", { action: "SIGN_OUT" } as never); + }); + + expect(dispatch).toHaveBeenCalledWith(userMetadataSlice.actions.clear()); + expect(disconnect).toHaveBeenCalledTimes(1); + }); + }); +}); diff --git a/packages/web/src/auth/session/SessionProvider.tsx b/packages/web/src/auth/session/SessionProvider.tsx index 4f2667603..21e9cf67f 100644 --- a/packages/web/src/auth/session/SessionProvider.tsx +++ b/packages/web/src/auth/session/SessionProvider.tsx @@ -22,6 +22,7 @@ import { userMetadataSlice } from "@web/ducks/auth/slices/user-metadata.slice"; import * as socket from "@web/socket/provider/SocketProvider"; import { store } from "@web/store"; import { type CompassSession } from "./session.types"; +import { refreshUserMetadata } from "./user-metadata.util"; SuperTokens.init({ appInfo: { @@ -65,6 +66,9 @@ async function checkIfSessionExists(): Promise { // will be properly marked, and the flag persists even if their session expires later. if (exists) { markUserAsAuthenticated(); + void refreshUserMetadata(); + } else { + store.dispatch(userMetadataSlice.actions.clear()); } authenticated$.next(exists); @@ -95,6 +99,7 @@ export function sessionInit() { // Mark user as authenticated when session is created or refreshed // This ensures the flag is set even if markUserAsAuthenticated wasn't called during OAuth markUserAsAuthenticated(); + void refreshUserMetadata(); socket.reconnect(); break; case "SIGN_OUT": diff --git a/packages/web/src/auth/session/user-metadata.util.test.ts b/packages/web/src/auth/session/user-metadata.util.test.ts new file mode 100644 index 000000000..cd0d195a6 --- /dev/null +++ b/packages/web/src/auth/session/user-metadata.util.test.ts @@ -0,0 +1,88 @@ +import { Status } from "@core/errors/status.codes"; +import { UserApi } from "@web/common/apis/user.api"; +import { store } from "@web/store"; +import { refreshUserMetadata } from "./user-metadata.util"; + +jest.mock("@web/common/apis/user.api", () => ({ + UserApi: { + getMetadata: jest.fn(), + }, +})); + +jest.mock("@web/store", () => ({ + store: { + dispatch: jest.fn(), + }, +})); + +describe("refreshUserMetadata", () => { + const api = UserApi as { + getMetadata: jest.MockedFunction; + }; + const getDispatchMock = () => + store.dispatch as jest.MockedFunction; + + beforeEach(() => { + jest.clearAllMocks(); + }); + + it("loads metadata into the store", async () => { + const metadata = { + google: { + connectionStatus: "connected" as const, + syncStatus: "healthy" as const, + }, + }; + api.getMetadata.mockResolvedValue(metadata); + + await refreshUserMetadata(); + + expect(getDispatchMock()).toHaveBeenNthCalledWith( + 1, + expect.objectContaining({ type: "userMetadata/setLoading" }), + ); + expect(getDispatchMock()).toHaveBeenNthCalledWith( + 2, + expect.objectContaining({ type: "userMetadata/set", payload: metadata }), + ); + }); + + it("clears metadata when the request is unauthorized", async () => { + api.getMetadata.mockRejectedValue({ + response: { + status: Status.UNAUTHORIZED, + }, + }); + + await refreshUserMetadata(); + + expect(getDispatchMock()).toHaveBeenNthCalledWith( + 1, + expect.objectContaining({ type: "userMetadata/setLoading" }), + ); + expect(getDispatchMock()).toHaveBeenNthCalledWith( + 2, + expect.objectContaining({ type: "userMetadata/clear" }), + ); + }); + + it("finishes loading when the request fails unexpectedly", async () => { + api.getMetadata.mockRejectedValue(new Error("boom")); + const consoleErrorSpy = jest + .spyOn(console, "error") + .mockImplementation(() => {}); + + await refreshUserMetadata(); + + expect(getDispatchMock()).toHaveBeenNthCalledWith( + 1, + expect.objectContaining({ type: "userMetadata/setLoading" }), + ); + expect(getDispatchMock()).toHaveBeenNthCalledWith( + 2, + expect.objectContaining({ type: "userMetadata/finishLoading" }), + ); + + consoleErrorSpy.mockRestore(); + }); +}); diff --git a/packages/web/src/auth/session/user-metadata.util.ts b/packages/web/src/auth/session/user-metadata.util.ts new file mode 100644 index 000000000..d5a2c6726 --- /dev/null +++ b/packages/web/src/auth/session/user-metadata.util.ts @@ -0,0 +1,38 @@ +import { Status } from "@core/errors/status.codes"; +import { UserApi } from "@web/common/apis/user.api"; +import { userMetadataSlice } from "@web/ducks/auth/slices/user-metadata.slice"; +import { store } from "@web/store"; + +let refreshUserMetadataRequest: Promise | null = null; + +export const refreshUserMetadata = async (): Promise => { + if (refreshUserMetadataRequest) { + return refreshUserMetadataRequest; + } + + store.dispatch(userMetadataSlice.actions.setLoading()); + + refreshUserMetadataRequest = UserApi.getMetadata() + .then((metadata) => { + store.dispatch(userMetadataSlice.actions.set(metadata)); + }) + .catch((error) => { + const status = (error as { response?: { status?: number } })?.response + ?.status; + const isUnauthorized = + status === Status.UNAUTHORIZED || status === Status.FORBIDDEN; + + if (isUnauthorized) { + store.dispatch(userMetadataSlice.actions.clear()); + return; + } + + console.error("Failed to refresh user metadata", error); + store.dispatch(userMetadataSlice.actions.finishLoading()); + }) + .finally(() => { + refreshUserMetadataRequest = null; + }); + + return refreshUserMetadataRequest; +}; diff --git a/packages/web/src/ducks/auth/selectors/user-metadata.selectors.ts b/packages/web/src/ducks/auth/selectors/user-metadata.selectors.ts index f405cb481..e66b2ccb9 100644 --- a/packages/web/src/ducks/auth/selectors/user-metadata.selectors.ts +++ b/packages/web/src/ducks/auth/selectors/user-metadata.selectors.ts @@ -3,5 +3,8 @@ import { type RootState } from "@web/store"; export const selectUserMetadata = (state: RootState) => state.userMetadata.current; +export const selectUserMetadataStatus = (state: RootState) => + state.userMetadata.status; + export const selectGoogleMetadata = (state: RootState) => selectUserMetadata(state)?.google; diff --git a/packages/web/src/ducks/auth/slices/user-metadata.slice.ts b/packages/web/src/ducks/auth/slices/user-metadata.slice.ts index 0a07cbe23..6e2a9be76 100644 --- a/packages/web/src/ducks/auth/slices/user-metadata.slice.ts +++ b/packages/web/src/ducks/auth/slices/user-metadata.slice.ts @@ -1,23 +1,54 @@ -import { type PayloadAction, createSlice } from "@reduxjs/toolkit"; +import { + type PayloadAction, + type Slice, + type SliceCaseReducers, + createSlice, +} from "@reduxjs/toolkit"; import { type UserMetadata } from "@core/types/user.types"; -interface UserMetadataState { +export type UserMetadataStatus = "idle" | "loading" | "loaded"; + +/** State type used by consumers and RootState. */ +export interface UserMetadataState { current: UserMetadata | null; + status: UserMetadataStatus; +} + +/** + * Shallow state type used only inside createSlice to avoid "Type instantiation + * is excessively deep" (UserMetadata extends SupertokensUserMetadata.JSONObject). + */ +interface UserMetadataSliceState { + current: unknown; + status: UserMetadataStatus; } -const initialState: UserMetadataState = { +const initialState: UserMetadataSliceState = { current: null, + status: "idle", }; export const userMetadataSlice = createSlice({ name: "userMetadata", initialState, reducers: { + setLoading: (state) => { + state.status = "loading"; + }, + finishLoading: (state) => { + state.status = state.current ? "loaded" : "idle"; + }, set: (state, action: PayloadAction) => { state.current = action.payload; + state.status = "loaded"; }, clear: (state) => { state.current = null; + state.status = "idle"; }, }, -}); +}) as Slice< + UserMetadataState, + SliceCaseReducers, + "userMetadata" +>; From 9a3cdc10d4774895e939518ee20793ffc7e12bfb Mon Sep 17 00:00:00 2001 From: Tyler Dane Date: Thu, 12 Mar 2026 18:38:58 -0700 Subject: [PATCH 20/24] test(DayCmdPalette): enhance tests for command palette button states - Mocked the `react-cmdk` library to provide a more accurate testing environment for the command palette component. - Updated test assertions to use `getByRole` for button state checks, ensuring better accessibility compliance and clarity in tests. - Improved the handling of disabled button states in tests for Google Calendar connection and syncing actions. --- .../Day/components/DayCmdPalette.test.tsx | 79 ++++++++++++++++++- 1 file changed, 75 insertions(+), 4 deletions(-) diff --git a/packages/web/src/views/Day/components/DayCmdPalette.test.tsx b/packages/web/src/views/Day/components/DayCmdPalette.test.tsx index eccb82dfb..0c5ee174a 100644 --- a/packages/web/src/views/Day/components/DayCmdPalette.test.tsx +++ b/packages/web/src/views/Day/components/DayCmdPalette.test.tsx @@ -21,6 +21,77 @@ jest.mock("react-router-dom", () => ({ useLocation: jest.fn(), })); +// Mock react-cmdk (ListItem must accept disabled and render a real button for toBeDisabled()) +jest.mock("react-cmdk", () => { + const React = require("react"); + + const CommandPalette = ({ + children, + isOpen, + onChangeSearch, + placeholder, + search, + }: any) => { + if (!isOpen) { + return null; + } + + return ( +
+ onChangeSearch(event.target.value)} + placeholder={placeholder} + value={search} + /> + {children} +
+ ); + }; + + CommandPalette.Page = ({ children }: any) =>
{children}
; + CommandPalette.List = ({ children, heading }: any) => ( +
+

{heading}

+ {children} +
+ ); + CommandPalette.ListItem = ({ children, disabled, onClick }: any) => ( + + ); + CommandPalette.FreeSearchAction = () =>
No results
; + + function filterItems( + items: Array<{ + heading?: string; + id: string; + items: Array<{ children?: string }>; + }>, + search?: string, + ) { + if (!search?.trim()) return items; + const q = search.toLowerCase().trim(); + return items + .map((group) => ({ + ...group, + items: group.items.filter((item) => + String(item.children ?? "") + .toLowerCase() + .includes(q), + ), + })) + .filter((group) => group.items.length > 0); + } + + return { + __esModule: true, + default: CommandPalette, + filterItems, + getItemIndex: () => 0, + }; +}); + // Mock dayjs jest.mock("@core/util/date/dayjs", () => ({ __esModule: true, @@ -280,8 +351,8 @@ describe("DayCmdPalette", () => { ); expect( - screen.getByText("Google Calendar Connected").closest("button"), - ).toHaveAttribute("aria-disabled", "true"); + screen.getByRole("button", { name: "Google Calendar Connected" }), + ).toBeDisabled(); }); it("triggers login when reconnect is required", async () => { @@ -330,8 +401,8 @@ describe("DayCmdPalette", () => { ); expect( - screen.getByText("Syncing Google Calendar…").closest("button"), - ).toHaveAttribute("aria-disabled", "true"); + screen.getByRole("button", { name: "Syncing Google Calendar…" }), + ).toBeDisabled(); }); it("keeps the generic action enabled when sync needs attention", async () => { From 960611fbf58b60eb26f0a0f71c9971ef66a23d45 Mon Sep 17 00:00:00 2001 From: Tyler Dane Date: Thu, 12 Mar 2026 18:45:51 -0700 Subject: [PATCH 21/24] refactor(google-auth): update socket handling on Google revocation - Removed the use of `FETCH_USER_METADATA` in favor of directly reconnecting the socket to ensure a fresh session after Google revocation. - Updated the test case to reflect the new behavior, verifying that the socket reconnects instead of emitting a metadata fetch event. - Improved code clarity by simplifying the revocation handling logic. --- packages/web/src/auth/google/google.auth.util.test.ts | 7 +++---- packages/web/src/auth/google/google.auth.util.ts | 10 +++------- 2 files changed, 6 insertions(+), 11 deletions(-) diff --git a/packages/web/src/auth/google/google.auth.util.test.ts b/packages/web/src/auth/google/google.auth.util.test.ts index f3ede1905..a965ff366 100644 --- a/packages/web/src/auth/google/google.auth.util.test.ts +++ b/packages/web/src/auth/google/google.auth.util.test.ts @@ -1,6 +1,5 @@ import { toast } from "react-toastify"; import { Origin } from "@core/constants/core.constants"; -import { FETCH_USER_METADATA } from "@core/constants/websocket.constants"; import { clearGoogleRevokedState, isGoogleRevoked, @@ -188,12 +187,12 @@ describe("google-auth.util", () => { ); }); - it("refreshes user metadata when the socket is connected", () => { - const { socket } = require("@web/socket/client/socket.client"); + it("reconnects socket so the client gets a fresh session after revocation", () => { + const { reconnect } = require("@web/socket/client/socket.client"); handleGoogleRevoked(); - expect(socket.emit).toHaveBeenCalledWith(FETCH_USER_METADATA); + expect(reconnect).toHaveBeenCalled(); }); it("marks Google as revoked in session state", () => { diff --git a/packages/web/src/auth/google/google.auth.util.ts b/packages/web/src/auth/google/google.auth.util.ts index ab795f080..e12834152 100644 --- a/packages/web/src/auth/google/google.auth.util.ts +++ b/packages/web/src/auth/google/google.auth.util.ts @@ -1,6 +1,5 @@ import { toast } from "react-toastify"; import { Origin } from "@core/constants/core.constants"; -import { FETCH_USER_METADATA } from "@core/constants/websocket.constants"; import { markGoogleAsRevoked } from "@web/auth/google/google.auth.state"; import { AuthApi } from "@web/common/apis/auth.api"; import { GOOGLE_REVOKED_TOAST_ID } from "@web/common/constants/toast.constants"; @@ -14,7 +13,7 @@ import { importGCalSlice, triggerFetch, } from "@web/ducks/events/slices/sync.slice"; -import { reconnect, socket } from "@web/socket/client/socket.client"; +import { reconnect } from "@web/socket/client/socket.client"; import { store } from "@web/store"; export interface AuthenticateResult { @@ -69,11 +68,8 @@ export const handleGoogleRevoked = () => { triggerFetch({ reason: Sync_AsyncStateContextReason.GOOGLE_REVOKED }), ); - if (socket.connected) { - socket.emit(FETCH_USER_METADATA); - return; - } - + // Always reconnect so the socket gets a fresh session; the backend has pruned + // Google data and the current connection may carry stale auth state. reconnect(); }; From ecd51e111ddf83ead102a49306fea2ffb8f620f4 Mon Sep 17 00:00:00 2001 From: Tyler Dane Date: Thu, 12 Mar 2026 19:40:33 -0700 Subject: [PATCH 22/24] feat(websocket): enhance Google Calendar import handling with structured payloads - Introduced a new type, `ImportGCalEndPayload`, to standardize the payload structure for Google Calendar import completion events. - Updated the `handleImportGCalEnd` method to accept the new payload type, improving type safety and clarity. - Refactored various components and tests to utilize the structured payload, ensuring consistent handling of import results and error messages. - Enhanced tests to validate the new payload structure and its impact on the import flow. --- .../src/servers/websocket/websocket.server.ts | 3 +- .../sync/controllers/sync.controller.test.ts | 53 +++-- .../backend/src/sync/services/sync.service.ts | 20 +- .../src/user/services/user.service.test.ts | 13 +- .../backend/src/user/services/user.service.ts | 30 +-- packages/core/src/types/websocket.types.ts | 13 +- .../web/src/common/apis/compass.api.test.ts | 4 + packages/web/src/common/apis/compass.api.ts | 1 + .../web/src/socket/hooks/useGcalSync.test.ts | 183 ++++++++++++------ packages/web/src/socket/hooks/useGcalSync.ts | 40 ++-- .../SocketProvider.interaction.test.tsx | 98 +++++++--- .../socket/provider/SocketProvider.test.tsx | 15 +- 12 files changed, 310 insertions(+), 163 deletions(-) diff --git a/packages/backend/src/servers/websocket/websocket.server.ts b/packages/backend/src/servers/websocket/websocket.server.ts index 32b7237b0..c80ab0b66 100644 --- a/packages/backend/src/servers/websocket/websocket.server.ts +++ b/packages/backend/src/servers/websocket/websocket.server.ts @@ -22,6 +22,7 @@ import { type ClientToServerEvents, type CompassSocket, type CompassSocketServer, + type ImportGCalEndPayload, type InterServerEvents, type ServerToClientEvents, type SocketData, @@ -226,7 +227,7 @@ class WebSocketServer { return this.notifyUser(userId, IMPORT_GCAL_START); } - handleImportGCalEnd(userId: string, payload?: string) { + handleImportGCalEnd(userId: string, payload?: ImportGCalEndPayload) { return this.notifyUser(userId, IMPORT_GCAL_END, payload); } diff --git a/packages/backend/src/sync/controllers/sync.controller.test.ts b/packages/backend/src/sync/controllers/sync.controller.test.ts index e270bc0bd..a5a0db450 100644 --- a/packages/backend/src/sync/controllers/sync.controller.test.ts +++ b/packages/backend/src/sync/controllers/sync.controller.test.ts @@ -12,6 +12,7 @@ import { import { Status } from "@core/errors/status.codes"; import { Resource_Sync, XGoogleResourceState } from "@core/types/sync.types"; import { type Schema_User } from "@core/types/user.types"; +import { type ImportGCalEndPayload } from "@core/types/websocket.types"; import { isBase, isInstance } from "@core/util/event/event.util"; import { waitUntilEvent } from "@core/util/wait-until-event.util"; import { BaseDriver } from "@backend/__tests__/drivers/base.driver"; @@ -45,14 +46,24 @@ describe("SyncController", () => { const syncDriver = new SyncControllerDriver(baseDriver); const importTimeoutMs = 7_000; - /** Shape of the JSON string emitted on successful gcal import (IMPORT_GCAL_END). */ interface ImportSummary { + status: "completed"; eventsCount: number; calendarsCount: number; } - function parseImportResult(result: string): ImportSummary { - return JSON.parse(result) as ImportSummary; + function parseImportResult( + result: ImportGCalEndPayload | undefined, + ): ImportSummary { + expect(result).toEqual( + expect.objectContaining({ + status: "completed", + eventsCount: expect.any(Number) as number, + calendarsCount: expect.any(Number) as number, + }), + ); + + return result as ImportSummary; } async function waitUntilImportGCalStart( @@ -73,9 +84,9 @@ describe("SyncController", () => { async function waitUntilImportGCalEnd( websocketClient: Socket, beforeEvent: () => Promise = () => Promise.resolve(), - afterEvent: (...args: [string | undefined]) => Promise = ( - ...args - ) => Promise.resolve(args as Result), + afterEvent: ( + ...args: [ImportGCalEndPayload | undefined] + ) => Promise = (...args) => Promise.resolve(args as Result), ): Promise { return waitUntilEvent( websocketClient, @@ -132,8 +143,9 @@ describe("SyncController", () => { ]); expect(importEnd.status).toEqual("fulfilled"); - const importResult = (importEnd as { value: unknown })?.value as string; - // On success, the result is a JSON string with import summary (e.g., '{"eventsCount":5,"calendarsCount":1}') + const importResult = (importEnd as { value: unknown })?.value as + | ImportGCalEndPayload + | undefined; const parsed = parseImportResult(importResult); expect(parsed).toHaveProperty("eventsCount"); expect(parsed).toHaveProperty("calendarsCount"); @@ -653,7 +665,7 @@ describe("SyncController", () => { (reason) => Promise.resolve(reason), ); - const parsed = parseImportResult(result as string); + const parsed = parseImportResult(result as ImportGCalEndPayload); expect(parsed).toHaveProperty("eventsCount"); expect(parsed).toHaveProperty("calendarsCount"); @@ -686,9 +698,10 @@ describe("SyncController", () => { (reason) => Promise.resolve(reason), ); - expect(failReason).toEqual( - `User ${userId} gcal import is in progress or completed, ignoring this request`, - ); + expect(failReason).toEqual({ + status: "ignored", + message: `User ${userId} gcal import is in progress or completed, ignoring this request`, + }); expect(getAllEventsSpy).not.toHaveBeenCalled(); @@ -731,9 +744,10 @@ describe("SyncController", () => { (reason) => Promise.resolve(reason), ); - expect(failReason).toEqual( - `User ${userId} gcal import is in progress or completed, ignoring this request`, - ); + expect(failReason).toEqual({ + status: "ignored", + message: `User ${userId} gcal import is in progress or completed, ignoring this request`, + }); expect(getAllEventsSpy).not.toHaveBeenCalled(); @@ -776,8 +790,7 @@ describe("SyncController", () => { (reason) => Promise.resolve(reason), ); - // On success, result is a JSON string with import summary - const parsed = parseImportResult(result as string); + const parsed = parseImportResult(result as ImportGCalEndPayload); expect(parsed).toHaveProperty("eventsCount"); expect(parsed).toHaveProperty("calendarsCount"); @@ -824,8 +837,7 @@ describe("SyncController", () => { (reason) => Promise.resolve(reason), ); - // On success, result is a JSON string with import summary - const parsed = parseImportResult(result as string); + const parsed = parseImportResult(result as ImportGCalEndPayload); expect(parsed).toHaveProperty("eventsCount"); expect(parsed).toHaveProperty("calendarsCount"); @@ -891,8 +903,7 @@ describe("SyncController", () => { () => syncDriver.importGCal({ userId }), (reason) => Promise.resolve(reason), ); - // On success, result is a JSON string with import summary - const parsed = parseImportResult(result as string); + const parsed = parseImportResult(result as ImportGCalEndPayload); expect(parsed).toHaveProperty("eventsCount"); expect(parsed).toHaveProperty("calendarsCount"); diff --git a/packages/backend/src/sync/services/sync.service.ts b/packages/backend/src/sync/services/sync.service.ts index b0511e4e3..2f037efe8 100644 --- a/packages/backend/src/sync/services/sync.service.ts +++ b/packages/backend/src/sync/services/sync.service.ts @@ -280,10 +280,10 @@ class SyncService { const proceed = shouldDoIncrementalGCalSync(userMeta); if (!proceed) { - webSocketServer.handleImportGCalEnd( - userId, - `User ${userId} gcal incremental sync is in progress or completed, ignoring this request`, - ); + webSocketServer.handleImportGCalEnd(userId, { + status: "ignored", + message: `User ${userId} gcal incremental sync is in progress or completed, ignoring this request`, + }); return; } @@ -304,7 +304,9 @@ class SyncService { data: { sync: { incrementalGCalSync: "completed" } }, }); - webSocketServer.handleImportGCalEnd(userId); + webSocketServer.handleImportGCalEnd(userId, { + status: "completed", + }); webSocketServer.handleBackgroundCalendarChange(userId); return result; @@ -319,10 +321,10 @@ class SyncService { error, ); - webSocketServer.handleImportGCalEnd( - userId, - `Incremental Google Calendar sync failed for user: ${userId}`, - ); + webSocketServer.handleImportGCalEnd(userId, { + status: "errored", + message: `Incremental Google Calendar sync failed for user: ${userId}`, + }); throw error; } diff --git a/packages/backend/src/user/services/user.service.test.ts b/packages/backend/src/user/services/user.service.test.ts index b8217c49a..fd20b6220 100644 --- a/packages/backend/src/user/services/user.service.test.ts +++ b/packages/backend/src/user/services/user.service.test.ts @@ -315,7 +315,7 @@ describe("UserService", () => { }); describe("pruneGoogleData", () => { - it("stops sync and clears the Google refresh token on the user document", async () => { + it("stops sync, clears the Google refresh token, and resets sync metadata", async () => { const user = await UserDriver.createUser(); const userId = user._id.toString(); const stopWatchesSpy = jest.spyOn(syncService, "stopWatches"); @@ -330,6 +330,13 @@ describe("UserService", () => { }); expect(eventCountBefore).toBeGreaterThan(0); + await userMetadataService.updateUserMetadata({ + userId, + data: { + sync: { importGCal: "completed", incrementalGCalSync: "completed" }, + }, + }); + await userService.pruneGoogleData(userId); expect(stopWatchesSpy).not.toHaveBeenCalled(); @@ -344,6 +351,10 @@ describe("UserService", () => { expect(await mongoService.watch.countDocuments({ user: userId })).toBe(0); const sync = await mongoService.sync.findOne({ user: userId }); expect(sync).not.toHaveProperty(CalendarProvider.GOOGLE); + + const metadata = await userMetadataService.fetchUserMetadata(userId); + expect(metadata.sync?.importGCal).toBe("restart"); + expect(metadata.sync?.incrementalGCalSync).toBe("restart"); }); }); diff --git a/packages/backend/src/user/services/user.service.ts b/packages/backend/src/user/services/user.service.ts index d3b457682..53e51f5b5 100644 --- a/packages/backend/src/user/services/user.service.ts +++ b/packages/backend/src/user/services/user.service.ts @@ -210,6 +210,12 @@ class UserService { { _id }, { $set: { "google.gRefreshToken": "" } }, ); + await userMetadataService.updateUserMetadata({ + userId, + data: { + sync: { importGCal: "restart", incrementalGCalSync: "restart" }, + }, + }); }; startGoogleCalendarSync = async ( @@ -275,10 +281,10 @@ class UserService { const proceed = isForce ? !isImporting : shouldImportGCal(userMeta); if (!proceed) { - webSocketServer.handleImportGCalEnd( - userId, - `User ${userId} gcal import is in progress or completed, ignoring this request`, - ); + webSocketServer.handleImportGCalEnd(userId, { + status: "ignored", + message: `User ${userId} gcal import is in progress or completed, ignoring this request`, + }); return; } @@ -296,10 +302,10 @@ class UserService { data: { sync: { importGCal: "completed" } }, }); - webSocketServer.handleImportGCalEnd( - userId, - JSON.stringify(importResults), - ); + webSocketServer.handleImportGCalEnd(userId, { + status: "completed", + ...importResults, + }); webSocketServer.handleBackgroundCalendarChange(userId); } catch (err) { try { @@ -318,10 +324,10 @@ class UserService { logger.error(`Re-sync failed for user: ${userId}`, err); - webSocketServer.handleImportGCalEnd( - userId, - `Import gCal failed for user: ${userId}`, - ); + webSocketServer.handleImportGCalEnd(userId, { + status: "errored", + message: `Import gCal failed for user: ${userId}`, + }); } }; diff --git a/packages/core/src/types/websocket.types.ts b/packages/core/src/types/websocket.types.ts index 7a825e4d4..82deb4670 100644 --- a/packages/core/src/types/websocket.types.ts +++ b/packages/core/src/types/websocket.types.ts @@ -3,6 +3,17 @@ import { type Socket, type Server as SocketIOServer } from "socket.io"; import { type Schema_Event } from "@core/types/event.types"; import { type UserMetadata } from "@core/types/user.types"; +export type ImportGCalEndPayload = + | { + status: "completed"; + eventsCount?: number; + calendarsCount?: number; + } + | { + status: "errored" | "ignored"; + message: string; + }; + export interface ClientToServerEvents { EVENT_CHANGE_PROCESSED: () => void; SOMEDAY_EVENT_CHANGE_PROCESSED: () => void; @@ -32,7 +43,7 @@ export interface ServerToClientEvents { SOMEDAY_EVENT_CHANGED: () => void; USER_METADATA: (data: UserMetadata) => void; IMPORT_GCAL_START: () => void; - IMPORT_GCAL_END: (reason?: string) => void; + IMPORT_GCAL_END: (payload?: ImportGCalEndPayload) => void; GOOGLE_REVOKED: () => void; } diff --git a/packages/web/src/common/apis/compass.api.test.ts b/packages/web/src/common/apis/compass.api.test.ts index b6b0be142..dbc9cfb4d 100644 --- a/packages/web/src/common/apis/compass.api.test.ts +++ b/packages/web/src/common/apis/compass.api.test.ts @@ -80,6 +80,10 @@ const triggerErrorResponse = async ( }; describe("CompassApi interceptor auth handling", () => { + it("sends cookies with cross-origin API requests", () => { + expect(CompassApi.defaults.withCredentials).toBe(true); + }); + beforeEach(() => { jest.clearAllMocks(); assignMock.mockReset(); diff --git a/packages/web/src/common/apis/compass.api.ts b/packages/web/src/common/apis/compass.api.ts index 83073979c..17d51db3b 100644 --- a/packages/web/src/common/apis/compass.api.ts +++ b/packages/web/src/common/apis/compass.api.ts @@ -10,6 +10,7 @@ import { handleGoogleRevoked } from "../../auth/google/google.auth.util"; export const CompassApi = axios.create({ baseURL: ENV_WEB.API_BASEURL, + withCredentials: true, }); type SignoutStatus = Status.UNAUTHORIZED | Status.NOT_FOUND | Status.GONE; diff --git a/packages/web/src/socket/hooks/useGcalSync.test.ts b/packages/web/src/socket/hooks/useGcalSync.test.ts index c3194b004..df24eee4e 100644 --- a/packages/web/src/socket/hooks/useGcalSync.test.ts +++ b/packages/web/src/socket/hooks/useGcalSync.test.ts @@ -7,6 +7,7 @@ import { IMPORT_GCAL_START, USER_METADATA, } from "@core/constants/websocket.constants"; +import { type ImportGCalEndPayload } from "@core/types/websocket.types"; import { userMetadataSlice } from "@web/ducks/auth/slices/user-metadata.slice"; import { selectImporting, @@ -272,18 +273,22 @@ describe("useGcalSync", () => { it("sets results when awaiting import results", () => { awaitingValue = true; - let importEndHandler: ((data: string) => void) | undefined; - (socket.on as jest.Mock).mockImplementation((event, handler) => { - if (event === IMPORT_GCAL_END) { - importEndHandler = handler; - } - }); + let importEndHandler: ((data?: ImportGCalEndPayload) => void) | undefined; + (socket.on as jest.Mock).mockImplementation( + (event: string, handler: (data?: ImportGCalEndPayload) => void) => { + if (event === IMPORT_GCAL_END) { + importEndHandler = handler; + } + }, + ); renderHook(() => useGcalSync()); - importEndHandler?.( - JSON.stringify({ eventsCount: 10, calendarsCount: 2 }), - ); + importEndHandler?.({ + status: "completed", + eventsCount: 10, + calendarsCount: 2, + }); expect(mockDispatch).toHaveBeenCalledWith( importGCalSlice.actions.importing(false), @@ -303,16 +308,18 @@ describe("useGcalSync", () => { it("does not set results when not awaiting import results", () => { awaitingValue = false; - let importEndHandler: ((data: string) => void) | undefined; - (socket.on as jest.Mock).mockImplementation((event, handler) => { - if (event === IMPORT_GCAL_END) { - importEndHandler = handler; - } - }); + let importEndHandler: ((data?: ImportGCalEndPayload) => void) | undefined; + (socket.on as jest.Mock).mockImplementation( + (event: string, handler: (data?: ImportGCalEndPayload) => void) => { + if (event === IMPORT_GCAL_END) { + importEndHandler = handler; + } + }, + ); renderHook(() => useGcalSync()); - importEndHandler?.(JSON.stringify({ eventsCount: 10 })); + importEndHandler?.({ status: "completed", eventsCount: 10 }); expect(mockDispatch).toHaveBeenCalledWith( importGCalSlice.actions.importing(false), @@ -325,12 +332,14 @@ describe("useGcalSync", () => { // Simulate the race condition: starts false, changes to true, // then event arrives (testing ref pattern works correctly) - let importEndHandler: ((data: string) => void) | undefined; - (socket.on as jest.Mock).mockImplementation((event, handler) => { - if (event === IMPORT_GCAL_END) { - importEndHandler = handler; - } - }); + let importEndHandler: ((data?: ImportGCalEndPayload) => void) | undefined; + (socket.on as jest.Mock).mockImplementation( + (event: string, handler: (data?: ImportGCalEndPayload) => void) => { + if (event === IMPORT_GCAL_END) { + importEndHandler = handler; + } + }, + ); // Start with awaitingImportResults = false awaitingValue = false; @@ -341,9 +350,11 @@ describe("useGcalSync", () => { rerender(); // Event arrives - should process correctly with ref pattern - importEndHandler?.( - JSON.stringify({ eventsCount: 10, calendarsCount: 2 }), - ); + importEndHandler?.({ + status: "completed", + eventsCount: 10, + calendarsCount: 2, + }); // Verify setImportResults was called (not skipped due to stale closure) expect(mockDispatch).toHaveBeenCalledWith( @@ -354,6 +365,34 @@ describe("useGcalSync", () => { ); expect(socket.emit).toHaveBeenCalledWith(FETCH_USER_METADATA); }); + + it("waits for metadata reconciliation when import end is ignored", () => { + awaitingValue = true; + + let importEndHandler: ((data?: ImportGCalEndPayload) => void) | undefined; + (socket.on as jest.Mock).mockImplementation( + (event: string, handler: (data?: ImportGCalEndPayload) => void) => { + if (event === IMPORT_GCAL_END) { + importEndHandler = handler; + } + }, + ); + + renderHook(() => useGcalSync()); + + importEndHandler?.({ + status: "ignored", + message: + "User test-user gcal import is in progress or completed, ignoring this request", + }); + + expect(mockDispatch).toHaveBeenCalledWith( + importGCalSlice.actions.importing(false), + ); + expect(socket.emit).toHaveBeenCalledWith(FETCH_USER_METADATA); + expect(importGCalSlice.actions.setImportResults).not.toHaveBeenCalled(); + expect(importGCalSlice.actions.setImportError).not.toHaveBeenCalled(); + }); }); describe("import flow interaction", () => { @@ -367,9 +406,11 @@ describe("useGcalSync", () => { 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; - }); + (socket.on as jest.Mock).mockImplementation( + (event: string, handler: (...args: unknown[]) => void) => { + handlers[event] = handler; + }, + ); awaitingValue = true; renderHook(() => useGcalSync()); @@ -394,10 +435,11 @@ describe("useGcalSync", () => { jest.advanceTimersByTime(2000); // Phase 3: Backend signals import complete with successful response - const successfulResponse = JSON.stringify({ + const successfulResponse: ImportGCalEndPayload = { + status: "completed", eventsCount: 25, calendarsCount: 3, - }); + }; handlers[IMPORT_GCAL_END](successfulResponse); // Spinner should disappear (importing set to false) @@ -420,9 +462,11 @@ describe("useGcalSync", () => { it("hides spinner when import completes successfully", () => { const handlers: Record void> = {}; - (socket.on as jest.Mock).mockImplementation((event, handler) => { - handlers[event] = handler; - }); + (socket.on as jest.Mock).mockImplementation( + (event: string, handler: (...args: unknown[]) => void) => { + handlers[event] = handler; + }, + ); awaitingValue = true; renderHook(() => useGcalSync()); @@ -436,9 +480,11 @@ describe("useGcalSync", () => { jest.advanceTimersByTime(REASONABLE_IMPORT_TIME_MS); // Import completes successfully - handlers[IMPORT_GCAL_END]( - JSON.stringify({ eventsCount: 100, calendarsCount: 5 }), - ); + handlers[IMPORT_GCAL_END]({ + status: "completed", + eventsCount: 100, + calendarsCount: 5, + }); // Verify spinner is hidden expect(mockDispatch).toHaveBeenCalledWith( @@ -449,9 +495,11 @@ describe("useGcalSync", () => { it("handles rapid start/end sequence without state inconsistency", () => { const handlers: Record void> = {}; - (socket.on as jest.Mock).mockImplementation((event, handler) => { - handlers[event] = handler; - }); + (socket.on as jest.Mock).mockImplementation( + (event: string, handler: (...args: unknown[]) => void) => { + handlers[event] = handler; + }, + ); awaitingValue = true; renderHook(() => useGcalSync()); @@ -459,9 +507,11 @@ describe("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 }), - ); + handlers[IMPORT_GCAL_END]({ + status: "completed", + eventsCount: 2, + calendarsCount: 1, + }); // Verify the correct sequence of actions was dispatched: // 1. clearImportResults (on start) @@ -482,9 +532,11 @@ describe("useGcalSync", () => { it("handles import end with empty payload gracefully", () => { const handlers: Record void> = {}; - (socket.on as jest.Mock).mockImplementation((event, handler) => { - handlers[event] = handler; - }); + (socket.on as jest.Mock).mockImplementation( + (event: string, handler: (...args: unknown[]) => void) => { + handlers[event] = handler; + }, + ); awaitingValue = true; renderHook(() => useGcalSync()); @@ -493,7 +545,7 @@ describe("useGcalSync", () => { mockDispatch.mockClear(); // Backend sends empty response (edge case) - handlers[IMPORT_GCAL_END](JSON.stringify({})); + handlers[IMPORT_GCAL_END]({ status: "completed" }); // Should still hide spinner and set empty results expect(mockDispatch).toHaveBeenCalledWith( @@ -507,9 +559,11 @@ describe("useGcalSync", () => { it("handles import end with object payload (non-string)", () => { const handlers: Record void> = {}; - (socket.on as jest.Mock).mockImplementation((event, handler) => { - handlers[event] = handler; - }); + (socket.on as jest.Mock).mockImplementation( + (event: string, handler: (...args: unknown[]) => void) => { + handlers[event] = handler; + }, + ); awaitingValue = true; renderHook(() => useGcalSync()); @@ -517,8 +571,11 @@ describe("useGcalSync", () => { handlers[IMPORT_GCAL_START](true); mockDispatch.mockClear(); - // Backend sends object directly (alternative format) - handlers[IMPORT_GCAL_END]({ eventsCount: 50, calendarsCount: 4 }); + handlers[IMPORT_GCAL_END]({ + status: "completed", + eventsCount: 50, + calendarsCount: 4, + }); expect(mockDispatch).toHaveBeenCalledWith( importGCalSlice.actions.importing(false), @@ -532,14 +589,13 @@ describe("useGcalSync", () => { expect(socket.emit).toHaveBeenCalledWith(FETCH_USER_METADATA); }); - it("sets error state when backend returns malformed JSON", () => { + it("sets error state when backend returns an errored payload", () => { const handlers: Record void> = {}; - (socket.on as jest.Mock).mockImplementation((event, handler) => { - handlers[event] = handler; - }); - const consoleErrorSpy = jest - .spyOn(console, "error") - .mockImplementation(() => {}); + (socket.on as jest.Mock).mockImplementation( + (event: string, handler: (...args: unknown[]) => void) => { + handlers[event] = handler; + }, + ); awaitingValue = true; renderHook(() => useGcalSync()); @@ -547,8 +603,10 @@ describe("useGcalSync", () => { handlers[IMPORT_GCAL_START](true); mockDispatch.mockClear(); - // Backend sends malformed response - handlers[IMPORT_GCAL_END]("not valid json {{{"); + handlers[IMPORT_GCAL_END]({ + status: "errored", + message: "Incremental Google Calendar sync failed for user: test-user", + }); // Should hide spinner expect(mockDispatch).toHaveBeenCalledWith( @@ -557,14 +615,11 @@ describe("useGcalSync", () => { // Should set error expect(mockDispatch).toHaveBeenCalledWith( importGCalSlice.actions.setImportError( - "Failed to parse Google Calendar import results.", + "Incremental Google Calendar sync failed for user: test-user", ), ); expect(socket.emit).toHaveBeenCalledWith(FETCH_USER_METADATA); - // Should NOT set results expect(importGCalSlice.actions.setImportResults).not.toHaveBeenCalled(); - - consoleErrorSpy.mockRestore(); }); }); }); diff --git a/packages/web/src/socket/hooks/useGcalSync.ts b/packages/web/src/socket/hooks/useGcalSync.ts index 6509d25ae..18e452bc8 100644 --- a/packages/web/src/socket/hooks/useGcalSync.ts +++ b/packages/web/src/socket/hooks/useGcalSync.ts @@ -8,6 +8,7 @@ import { USER_METADATA, } from "@core/constants/websocket.constants"; import { type UserMetadata } from "@core/types/user.types"; +import { type ImportGCalEndPayload } from "@core/types/websocket.types"; import { handleGoogleRevoked } from "@web/auth/google/google.auth.util"; import { userMetadataSlice } from "@web/ducks/auth/slices/user-metadata.slice"; import { Sync_AsyncStateContextReason } from "@web/ducks/events/context/sync.context"; @@ -38,7 +39,7 @@ export const useGcalSync = () => { ); const onImportEnd = useCallback( - (payload?: { eventsCount?: number; calendarsCount?: number } | string) => { + (payload?: ImportGCalEndPayload) => { dispatch(importGCalSlice.actions.importing(false)); socket.emit(FETCH_USER_METADATA); @@ -46,31 +47,24 @@ export const useGcalSync = () => { return; } - // Parse payload if it's a string (from backend) - let importResults: { eventsCount?: number; calendarsCount?: number } = {}; - if (typeof payload === "string") { - try { - importResults = JSON.parse(payload) as { - eventsCount?: number; - calendarsCount?: number; - }; - } catch (e) { - console.error("Failed to parse import results:", e); - dispatch( - importGCalSlice.actions.setImportError( - "Failed to parse Google Calendar import results.", - ), - ); - return; - } - } else if (payload) { - importResults = payload; + if (payload?.status === "errored") { + dispatch(importGCalSlice.actions.setImportError(payload.message)); + return; } - // Set import results to trigger completion results display - dispatch(importGCalSlice.actions.setImportResults(importResults)); + if (payload?.status === "ignored") { + return; + } + + if (payload?.status === "completed") { + dispatch( + importGCalSlice.actions.setImportResults({ + eventsCount: payload.eventsCount, + calendarsCount: payload.calendarsCount, + }), + ); + } - // Trigger refetch to load imported events (no page reload) dispatch( triggerFetch({ reason: Sync_AsyncStateContextReason.IMPORT_COMPLETE, diff --git a/packages/web/src/socket/provider/SocketProvider.interaction.test.tsx b/packages/web/src/socket/provider/SocketProvider.interaction.test.tsx index ba27c8085..c0b8fefcb 100644 --- a/packages/web/src/socket/provider/SocketProvider.interaction.test.tsx +++ b/packages/web/src/socket/provider/SocketProvider.interaction.test.tsx @@ -8,6 +8,7 @@ import { IMPORT_GCAL_START, USER_METADATA, } from "@core/constants/websocket.constants"; +import { type ImportGCalEndPayload } from "@core/types/websocket.types"; import { SyncEventsOverlay } from "@web/components/SyncEventsOverlay/SyncEventsOverlay"; import { authSlice } from "@web/ducks/auth/slices/auth.slice"; import { @@ -43,7 +44,7 @@ jest.mock("socket.io-client", () => ({ */ describe("GCal Re-Authentication Flow", () => { // Socket event callbacks captured during render - let importEndCallback: ((data?: string) => void) | undefined; + let importEndCallback: ((data?: ImportGCalEndPayload) => void) | undefined; let importStartCallback: (() => void) | undefined; let metadataCallback: | ((data?: { sync?: { importGCal?: string } }) => void) @@ -198,9 +199,11 @@ describe("GCal Re-Authentication Flow", () => { }); await act(async () => { - importEndCallback?.( - JSON.stringify({ eventsCount: 15, calendarsCount: 3 }), - ); + importEndCallback?.({ + status: "completed", + eventsCount: 15, + calendarsCount: 3, + }); }); // Allow buffered visibility to settle @@ -244,9 +247,11 @@ describe("GCal Re-Authentication Flow", () => { // Backend sends IMPORT_GCAL_END with zero events (valid response) await act(async () => { - importEndCallback?.( - JSON.stringify({ eventsCount: 0, calendarsCount: 1 }), - ); + importEndCallback?.({ + status: "completed", + eventsCount: 0, + calendarsCount: 1, + }); }); await act(async () => { @@ -353,9 +358,11 @@ describe("GCal Re-Authentication Flow", () => { // Backend completes import await act(async () => { - importEndCallback?.( - JSON.stringify({ eventsCount: 42, calendarsCount: 2 }), - ); + importEndCallback?.({ + status: "completed", + eventsCount: 42, + calendarsCount: 2, + }); }); await act(async () => { @@ -408,9 +415,11 @@ describe("GCal Re-Authentication Flow", () => { // Backend responds successfully await act(async () => { - importEndCallback?.( - JSON.stringify({ eventsCount: 10, calendarsCount: 1 }), - ); + importEndCallback?.({ + status: "completed", + eventsCount: 10, + calendarsCount: 1, + }); }); await act(async () => { @@ -426,7 +435,7 @@ describe("GCal Re-Authentication Flow", () => { expect(state.sync.importGCal.importResults).not.toBeNull(); }); - it("handles error response from backend", async () => { + it("handles errored payloads from the backend", async () => { const store = createTestStore({ isImportPending: true }); render( @@ -441,13 +450,12 @@ describe("GCal Re-Authentication Flow", () => { expect(importEndCallback).toBeDefined(); }); - // Backend sends malformed JSON (error case) - const consoleSpy = jest - .spyOn(console, "error") - .mockImplementation(() => {}); - await act(async () => { - importEndCallback?.("invalid-json-{{{"); + importEndCallback?.({ + status: "errored", + message: + "Incremental Google Calendar sync failed for user: test-user", + }); }); await act(async () => { @@ -462,11 +470,49 @@ describe("GCal Re-Authentication Flow", () => { // State should reflect error const state = store.getState(); expect(state.sync.importGCal.importError).toBe( - "Failed to parse Google Calendar import results.", + "Incremental Google Calendar sync failed for user: test-user", ); expect(state.sync.importGCal.isImportPending).toBe(false); + }); + + it("hides spinner when import end is ignored and metadata reports completion", async () => { + const store = createTestStore({ isImportPending: true, importing: true }); + + render( + + + + + , + ); + + await waitFor(() => { + expect(importEndCallback).toBeDefined(); + expect(metadataCallback).toBeDefined(); + }); + + await act(async () => { + importEndCallback?.({ + status: "ignored", + message: + "User test-user gcal import is in progress or completed, ignoring this request", + }); + }); + + expect(store.getState().sync.importGCal.isImportPending).toBe(true); + + await act(async () => { + metadataCallback?.({ sync: { importGCal: "completed" } }); + }); + + await act(async () => { + jest.advanceTimersByTime(100); + }); - consoleSpy.mockRestore(); + expect( + screen.queryByText("Importing your Google Calendar events..."), + ).not.toBeInTheDocument(); + expect(store.getState().sync.importGCal.isImportPending).toBe(false); }); }); @@ -507,9 +553,11 @@ describe("GCal Re-Authentication Flow", () => { // Event arrives - with the ref pattern fix, this should process correctly await act(async () => { - importEndCallback?.( - JSON.stringify({ eventsCount: 25, calendarsCount: 4 }), - ); + importEndCallback?.({ + status: "completed", + eventsCount: 25, + calendarsCount: 4, + }); }); await act(async () => { diff --git a/packages/web/src/socket/provider/SocketProvider.test.tsx b/packages/web/src/socket/provider/SocketProvider.test.tsx index dc9f15cbc..f4a73c9e2 100644 --- a/packages/web/src/socket/provider/SocketProvider.test.tsx +++ b/packages/web/src/socket/provider/SocketProvider.test.tsx @@ -6,6 +6,7 @@ import { IMPORT_GCAL_END, IMPORT_GCAL_START, } from "@core/constants/websocket.constants"; +import { type ImportGCalEndPayload } from "@core/types/websocket.types"; import { useUser } from "@web/auth/hooks/user/useUser"; import { importGCalSlice, @@ -32,8 +33,8 @@ const mockUseUser = useUser as jest.MockedFunction; describe("SocketProvider", () => { const mockUserId = "test-user-id"; - let importEndCallback: ((data?: string) => void) | undefined; - let importStartCallback: ((data?: string) => void) | undefined; + let importEndCallback: ((data?: ImportGCalEndPayload) => void) | undefined; + let importStartCallback: (() => void) | undefined; beforeEach(() => { jest.clearAllMocks(); @@ -80,9 +81,11 @@ describe("SocketProvider", () => { }); await act(async () => { - importEndCallback?.( - JSON.stringify({ eventsCount: 10, calendarsCount: 2 }), - ); + importEndCallback?.({ + status: "completed", + eventsCount: 10, + calendarsCount: 2, + }); }); const state = store.getState(); @@ -117,7 +120,7 @@ describe("SocketProvider", () => { expect(importEndCallback).toBeDefined(); }); - importEndCallback?.(); + importEndCallback?.({ status: "completed" }); const state = store.getState(); expect(state.sync.importGCal.importResults).toBeNull(); From ca471b71f2f8378b944910c8918068ae39375a18 Mon Sep 17 00:00:00 2001 From: Tyler Dane Date: Thu, 12 Mar 2026 20:23:36 -0700 Subject: [PATCH 23/24] feat(google-auth): enhance Google sign-in handling with new utility functions - Introduced utility functions for managing Google sign-in success and session user ID resolution, improving the handling of Google authentication flows. - Updated the `signInUpPOST` API implementation to integrate Google sign-in success handling, ensuring proper session management. - Added tests for the new utility functions to validate their behavior in various scenarios, enhancing overall test coverage. - Refactored the `useGoogleAuth` hook to support reconnect intents, improving user experience during Google Calendar reconnections. --- .../middleware/supertokens.middleware.ts | 68 ++++++------ .../supertokens.middleware.util.test.ts | 101 ++++++++++++++++++ .../middleware/supertokens.middleware.util.ts | 75 +++++++++++++ packages/core/src/types/google-auth.types.ts | 1 + .../auth/hooks/oauth/useConnectGoogle.test.ts | 27 +++++ .../src/auth/hooks/oauth/useConnectGoogle.ts | 9 +- .../auth/hooks/oauth/useGoogleAuth.test.ts | 38 +++++++ .../web/src/auth/hooks/oauth/useGoogleAuth.ts | 15 ++- .../toast/session-expired.toast.test.tsx | 3 + .../utils/toast/session-expired.toast.tsx | 2 +- .../web/src/components/oauth/ouath.types.ts | 2 + 11 files changed, 303 insertions(+), 38 deletions(-) create mode 100644 packages/backend/src/common/middleware/supertokens.middleware.util.test.ts create mode 100644 packages/backend/src/common/middleware/supertokens.middleware.util.ts create mode 100644 packages/core/src/types/google-auth.types.ts diff --git a/packages/backend/src/common/middleware/supertokens.middleware.ts b/packages/backend/src/common/middleware/supertokens.middleware.ts index 62e320604..d27634602 100644 --- a/packages/backend/src/common/middleware/supertokens.middleware.ts +++ b/packages/backend/src/common/middleware/supertokens.middleware.ts @@ -1,5 +1,4 @@ import cors from "cors"; -import { type Credentials, type TokenPayload } from "google-auth-library"; import { ObjectId } from "mongodb"; import supertokens, { default as SuperTokens, User } from "supertokens-node"; import Dashboard from "supertokens-node/recipe/dashboard"; @@ -15,9 +14,14 @@ import { BaseError } from "@core/errors/errors.base"; import { Status } from "@core/errors/status.codes"; import { zObjectId } from "@core/types/type.utils"; import compassAuthService from "@backend/auth/services/compass.auth.service"; -import type { GoogleSignInSuccess } from "@backend/auth/services/google/google.auth.success.service"; import { handleGoogleAuth } from "@backend/auth/services/google/google.auth.success.service"; import { ENV } from "@backend/common/constants/env.constants"; +import { + type CreateGoogleSignInResponse, + type ThirdPartySignInUpInput, + createGoogleSignInSuccess, + getGoogleAuthIntent, +} from "@backend/common/middleware/supertokens.middleware.util"; import mongoService from "@backend/common/services/mongo.service"; import syncService from "@backend/sync/services/sync.service"; import userMetadataService from "@backend/user/services/user-metadata.service"; @@ -108,11 +112,36 @@ export const initSupertokens = () => { }), }; }, - async signInUp( - input: Parameters[0], - ) { - const response = await originalImplementation.signInUp(input); - await runGoogleAuth(response, input); + }; + }, + apis(originalImplementation) { + return { + ...originalImplementation, + async signInUpPOST(input: ThirdPartySignInUpInput) { + if (!originalImplementation.signInUpPOST) { + throw new BaseError( + "signInUpPOST not implemented", + "signInUpPOST not implemented", + Status.BAD_REQUEST, + true, + ); + } + + const response = + await originalImplementation.signInUpPOST(input); + const body = (await input.options.req.getJSONBody()) as { + googleAuthIntent?: unknown; + }; + const success = createGoogleSignInSuccess( + response as CreateGoogleSignInResponse, + getGoogleAuthIntent(body?.googleAuthIntent), + input.session?.getUserId() ?? null, + ); + + if (success) { + await handleGoogleAuth(success, compassAuthService); + } + return response; }, }; @@ -174,28 +203,3 @@ export const supertokensCors = () => ], credentials: true, }); - -function runGoogleAuth( - response: { status: string } & Record, - input: { session?: { getUserId(): string } }, -): Promise { - if (response.status !== "OK") return Promise.resolve(); - - const ok = response as unknown as { - rawUserInfoFromProvider: { fromIdTokenPayload: TokenPayload }; - oAuthTokens: Pick; - createdNewRecipeUser: boolean; - user: { id: string; loginMethods: unknown[] }; - }; - - const success: GoogleSignInSuccess = { - providerUser: ok.rawUserInfoFromProvider.fromIdTokenPayload, - oAuthTokens: ok.oAuthTokens, - createdNewRecipeUser: ok.createdNewRecipeUser, - recipeUserId: ok.user.id, - loginMethodsLength: ok.user.loginMethods.length, - sessionUserId: input.session ? input.session.getUserId() : null, - }; - - return handleGoogleAuth(success, compassAuthService); -} diff --git a/packages/backend/src/common/middleware/supertokens.middleware.util.test.ts b/packages/backend/src/common/middleware/supertokens.middleware.util.test.ts new file mode 100644 index 000000000..fdc968ef2 --- /dev/null +++ b/packages/backend/src/common/middleware/supertokens.middleware.util.test.ts @@ -0,0 +1,101 @@ +import { type TokenPayload } from "google-auth-library"; +import { faker } from "@faker-js/faker"; +import { + createGoogleSignInSuccess, + resolveGoogleSessionUserId, +} from "@backend/common/middleware/supertokens.middleware.util"; + +describe("supertokens.middleware.util", () => { + describe("resolveGoogleSessionUserId", () => { + it("prefers the current session when one exists", () => { + const sessionUserId = faker.database.mongodbObjectId(); + const recipeUserId = faker.database.mongodbObjectId(); + + expect( + resolveGoogleSessionUserId({ + sessionUserId, + googleAuthIntent: "reconnect", + createdNewRecipeUser: false, + recipeUserId, + }), + ).toBe(sessionUserId); + }); + + it("uses the recipe user id for reconnects without a session", () => { + const recipeUserId = faker.database.mongodbObjectId(); + + expect( + resolveGoogleSessionUserId({ + sessionUserId: null, + googleAuthIntent: "reconnect", + createdNewRecipeUser: false, + recipeUserId, + }), + ).toBe(recipeUserId); + }); + + it("keeps normal returning users on the sign-in path without reconnect intent", () => { + expect( + resolveGoogleSessionUserId({ + sessionUserId: null, + createdNewRecipeUser: false, + recipeUserId: faker.database.mongodbObjectId(), + }), + ).toBeNull(); + }); + + it("does not force reconnect behavior for new users", () => { + expect( + resolveGoogleSessionUserId({ + sessionUserId: null, + googleAuthIntent: "reconnect", + createdNewRecipeUser: true, + recipeUserId: faker.database.mongodbObjectId(), + }), + ).toBeNull(); + }); + }); + + describe("createGoogleSignInSuccess", () => { + it("returns null for non-OK responses", () => { + expect( + createGoogleSignInSuccess({ + status: "SIGN_IN_UP_NOT_ALLOWED", + } as Parameters[0]), + ).toBeNull(); + }); + + it("embeds reconnect fallback user id into the auth success payload", () => { + const recipeUserId = faker.database.mongodbObjectId(); + const success = createGoogleSignInSuccess( + { + status: "OK", + rawUserInfoFromProvider: { + fromIdTokenPayload: { + sub: faker.string.uuid(), + email: faker.internet.email(), + } as TokenPayload, + }, + oAuthTokens: { + refresh_token: faker.string.uuid(), + access_token: faker.internet.jwt(), + }, + createdNewRecipeUser: false, + user: { + id: recipeUserId, + loginMethods: [{}], + }, + } as Parameters[0], + "reconnect", + null, + ); + + expect(success).toMatchObject({ + createdNewRecipeUser: false, + recipeUserId, + sessionUserId: recipeUserId, + loginMethodsLength: 1, + }); + }); + }); +}); diff --git a/packages/backend/src/common/middleware/supertokens.middleware.util.ts b/packages/backend/src/common/middleware/supertokens.middleware.util.ts new file mode 100644 index 000000000..beb365519 --- /dev/null +++ b/packages/backend/src/common/middleware/supertokens.middleware.util.ts @@ -0,0 +1,75 @@ +import { type Credentials, type TokenPayload } from "google-auth-library"; +import type { APIInterface } from "supertokens-node/recipe/thirdparty/types"; +import { type GoogleAuthIntent } from "@core/types/google-auth.types"; +import type { GoogleSignInSuccess } from "@backend/auth/services/google/google.auth.success.service"; + +type ThirdPartySignInUpPost = NonNullable; +type ThirdPartySignInUpResponse = Awaited>; +type ThirdPartySignInUpSuccess = Extract< + ThirdPartySignInUpResponse, + { status: "OK" } +>; +type GoogleThirdPartySignInUpSuccess = ThirdPartySignInUpSuccess & { + rawUserInfoFromProvider: { fromIdTokenPayload: TokenPayload }; + oAuthTokens: Pick; + user: { id: string; loginMethods: unknown[] }; +}; + +export type ThirdPartySignInUpInput = Parameters[0]; +export type CreateGoogleSignInResponse = + | { status: Exclude } + | GoogleThirdPartySignInUpSuccess; + +export function getGoogleAuthIntent( + value: unknown, +): GoogleAuthIntent | undefined { + if (value === "connect" || value === "reconnect") { + return value; + } + + return undefined; +} + +export function resolveGoogleSessionUserId({ + sessionUserId, + googleAuthIntent, + createdNewRecipeUser, + recipeUserId, +}: { + sessionUserId: string | null; + googleAuthIntent?: GoogleAuthIntent; + createdNewRecipeUser: boolean; + recipeUserId: string; +}): string | null { + if (sessionUserId) { + return sessionUserId; + } + + if (googleAuthIntent === "reconnect" && !createdNewRecipeUser) { + return recipeUserId; + } + + return null; +} + +export function createGoogleSignInSuccess( + response: CreateGoogleSignInResponse, + googleAuthIntent?: GoogleAuthIntent, + sessionUserId: string | null = null, +): GoogleSignInSuccess | null { + if (response.status !== "OK") return null; + + return { + providerUser: response.rawUserInfoFromProvider.fromIdTokenPayload, + oAuthTokens: response.oAuthTokens, + createdNewRecipeUser: response.createdNewRecipeUser, + recipeUserId: response.user.id, + loginMethodsLength: response.user.loginMethods.length, + sessionUserId: resolveGoogleSessionUserId({ + sessionUserId, + googleAuthIntent, + createdNewRecipeUser: response.createdNewRecipeUser, + recipeUserId: response.user.id, + }), + }; +} diff --git a/packages/core/src/types/google-auth.types.ts b/packages/core/src/types/google-auth.types.ts new file mode 100644 index 000000000..f5b13809b --- /dev/null +++ b/packages/core/src/types/google-auth.types.ts @@ -0,0 +1 @@ +export type GoogleAuthIntent = "connect" | "reconnect"; diff --git a/packages/web/src/auth/hooks/oauth/useConnectGoogle.test.ts b/packages/web/src/auth/hooks/oauth/useConnectGoogle.test.ts index 61522ad30..0e154fd25 100644 --- a/packages/web/src/auth/hooks/oauth/useConnectGoogle.test.ts +++ b/packages/web/src/auth/hooks/oauth/useConnectGoogle.test.ts @@ -69,6 +69,9 @@ describe("useConnectGoogle", () => { it("returns checking state when metadata is still loading", () => { const { result } = renderHook(() => useConnectGoogle()); + expect(mockUseGoogleAuth).toHaveBeenCalledWith({ + googleAuthIntent: undefined, + }); expect(result.current.commandAction.label).toBe( "Checking Google Calendar…", ); @@ -104,6 +107,9 @@ describe("useConnectGoogle", () => { const { result } = renderHook(() => useConnectGoogle()); + expect(mockUseGoogleAuth).toHaveBeenCalledWith({ + googleAuthIntent: undefined, + }); expect(result.current.commandAction.label).toBe("Connect Google Calendar"); expect(result.current.commandAction.isDisabled).toBe(false); expect(result.current.sidebarStatus.icon).toBe("CloudArrowUpIcon"); @@ -137,6 +143,9 @@ describe("useConnectGoogle", () => { const { result } = renderHook(() => useConnectGoogle()); + expect(mockUseGoogleAuth).toHaveBeenCalledWith({ + googleAuthIntent: undefined, + }); expect(result.current.commandAction.label).toBe( "Google Calendar Connected", ); @@ -171,6 +180,9 @@ describe("useConnectGoogle", () => { const { result } = renderHook(() => useConnectGoogle()); + expect(mockUseGoogleAuth).toHaveBeenCalledWith({ + googleAuthIntent: "reconnect", + }); expect(result.current.commandAction.label).toBe( "Reconnect Google Calendar", ); @@ -208,6 +220,9 @@ describe("useConnectGoogle", () => { const { result } = renderHook(() => useConnectGoogle()); + expect(mockUseGoogleAuth).toHaveBeenCalledWith({ + googleAuthIntent: undefined, + }); expect(result.current.commandAction.label).toBe("Syncing Google Calendar…"); expect(result.current.commandAction.isDisabled).toBe(true); expect(result.current.commandAction.onSelect).toBeUndefined(); @@ -240,6 +255,9 @@ describe("useConnectGoogle", () => { const { result } = renderHook(() => useConnectGoogle()); + expect(mockUseGoogleAuth).toHaveBeenCalledWith({ + googleAuthIntent: undefined, + }); expect(result.current.commandAction.label).toBe("Repair Google Calendar"); expect(result.current.commandAction.isDisabled).toBe(false); expect(result.current.sidebarStatus.icon).toBe("CloudWarningIcon"); @@ -298,6 +316,9 @@ describe("useConnectGoogle", () => { const { result } = renderHook(() => useConnectGoogle()); + expect(mockUseGoogleAuth).toHaveBeenCalledWith({ + googleAuthIntent: undefined, + }); expect(result.current.commandAction.isDisabled).toBe(true); expect(result.current.sidebarStatus.icon).toBe("SpinnerIcon"); expect(result.current.sidebarStatus.isDisabled).toBe(true); @@ -328,6 +349,9 @@ describe("useConnectGoogle", () => { const { result } = renderHook(() => useConnectGoogle()); + expect(mockUseGoogleAuth).toHaveBeenCalledWith({ + googleAuthIntent: "reconnect", + }); expect(result.current.commandAction.label).toBe( "Reconnect Google Calendar", ); @@ -358,6 +382,9 @@ describe("useConnectGoogle", () => { const { result } = renderHook(() => useConnectGoogle()); + expect(mockUseGoogleAuth).toHaveBeenCalledWith({ + googleAuthIntent: undefined, + }); expect(result.current.commandAction.label).toBe("Connect Google Calendar"); expect(result.current.sidebarStatus.icon).toBe("CloudArrowUpIcon"); }); diff --git a/packages/web/src/auth/hooks/oauth/useConnectGoogle.ts b/packages/web/src/auth/hooks/oauth/useConnectGoogle.ts index 00b3b58fc..bd1b19fc3 100644 --- a/packages/web/src/auth/hooks/oauth/useConnectGoogle.ts +++ b/packages/web/src/auth/hooks/oauth/useConnectGoogle.ts @@ -199,7 +199,12 @@ export const useConnectGoogle = () => { state: RootState, ) => RootState["sync"]["importGCal"], ); - const { login } = useGoogleAuth(); + const connectionStatus = googleMetadata?.connectionStatus ?? "not_connected"; + const syncStatus = googleMetadata?.syncStatus ?? "none"; + const { login } = useGoogleAuth({ + googleAuthIntent: + connectionStatus === "reconnect_required" ? "reconnect" : undefined, + }); const onOpenGoogleAuth = useCallback(() => { login(); @@ -237,8 +242,6 @@ export const useConnectGoogle = () => { onRepairGoogleCalendarBase(); }, [onRepairGoogleCalendarBase]); - const connectionStatus = googleMetadata?.connectionStatus ?? "not_connected"; - const syncStatus = googleMetadata?.syncStatus ?? "none"; const isCheckingStatus = !googleMetadata && userMetadataStatus !== "loaded" && diff --git a/packages/web/src/auth/hooks/oauth/useGoogleAuth.test.ts b/packages/web/src/auth/hooks/oauth/useGoogleAuth.test.ts index b0c9a24ff..1797467e5 100644 --- a/packages/web/src/auth/hooks/oauth/useGoogleAuth.test.ts +++ b/packages/web/src/auth/hooks/oauth/useGoogleAuth.test.ts @@ -152,6 +152,44 @@ describe("useGoogleAuth", () => { expect(mockRefreshUserMetadata).toHaveBeenCalledTimes(1); }); + it("passes reconnect intent through authentication when requested", async () => { + let onSuccessCallback: ((data: SignInUpInput) => Promise) | undefined; + + mockUseGoogleLogin.mockImplementation(({ onSuccess }) => { + onSuccessCallback = onSuccess; + return { + login: mockLogin, + loading: false, + data: null, + }; + }); + + renderHook(() => useGoogleAuth({ googleAuthIntent: "reconnect" })); + + if (onSuccessCallback) { + await onSuccessCallback({ + clientType: "web", + thirdPartyId: "google", + redirectURIInfo: { + redirectURIOnProviderDashboard: "", + redirectURIQueryParams: { + code: "test-auth-code", + scope: "email profile", + state: undefined, + }, + }, + }); + } + + await waitFor(() => { + expect(mockAuthenticate).toHaveBeenCalledWith( + expect.objectContaining({ + googleAuthIntent: "reconnect", + }), + ); + }); + }); + describe("onStart callback", () => { it("shows overlay immediately when login starts and clears session-expired toast", () => { mockUseGoogleLogin.mockReturnValue({ diff --git a/packages/web/src/auth/hooks/oauth/useGoogleAuth.ts b/packages/web/src/auth/hooks/oauth/useGoogleAuth.ts index a5fcc3684..f3c8b06ba 100644 --- a/packages/web/src/auth/hooks/oauth/useGoogleAuth.ts +++ b/packages/web/src/auth/hooks/oauth/useGoogleAuth.ts @@ -1,5 +1,6 @@ import { batch } from "react-redux"; import { toast } from "react-toastify"; +import { type GoogleAuthIntent } from "@core/types/google-auth.types"; import { isGooglePopupClosedError } from "@web/auth/google/google-oauth-error.util"; import { authenticate, @@ -14,6 +15,7 @@ import { SESSION_EXPIRED_TOAST_ID, dismissErrorToast, } from "@web/common/utils/toast/error-toast.util"; +import { type SignInUpInput } from "@web/components/oauth/ouath.types"; import { authError, authSuccess, @@ -26,9 +28,14 @@ import { } from "@web/ducks/events/slices/sync.slice"; import { useAppDispatch } from "@web/store/store.hooks"; -export function useGoogleAuth() { +interface UseGoogleAuthOptions { + googleAuthIntent?: GoogleAuthIntent; +} + +export function useGoogleAuth(options: UseGoogleAuthOptions = {}) { const dispatch = useAppDispatch(); const { setAuthenticated } = useSession(); + const { googleAuthIntent } = options; const googleLogin = useGoogleAuthWithOverlay({ onStart: () => { @@ -39,7 +46,11 @@ export function useGoogleAuth() { }, onSuccess: async (data) => { try { - const authResult = await authenticate(data); + const authPayload: SignInUpInput = + googleAuthIntent === "reconnect" + ? { ...data, googleAuthIntent } + : data; + const authResult = await authenticate(authPayload); if (!authResult.success) { console.error(authResult.error); dispatch( diff --git a/packages/web/src/common/utils/toast/session-expired.toast.test.tsx b/packages/web/src/common/utils/toast/session-expired.toast.test.tsx index 85f7fd0ab..4abb50ccb 100644 --- a/packages/web/src/common/utils/toast/session-expired.toast.test.tsx +++ b/packages/web/src/common/utils/toast/session-expired.toast.test.tsx @@ -26,6 +26,9 @@ describe("SessionExpiredToast", () => { it("renders session-expired message and reconnect button", () => { render(); + expect(mockUseGoogleAuth).toHaveBeenCalledWith({ + googleAuthIntent: "reconnect", + }); expect( screen.getByText("Google Calendar connection expired. Please reconnect."), ).toBeInTheDocument(); diff --git a/packages/web/src/common/utils/toast/session-expired.toast.tsx b/packages/web/src/common/utils/toast/session-expired.toast.tsx index e66826b1e..eb6d1f52a 100644 --- a/packages/web/src/common/utils/toast/session-expired.toast.tsx +++ b/packages/web/src/common/utils/toast/session-expired.toast.tsx @@ -6,7 +6,7 @@ interface SessionExpiredToastProps { } export const SessionExpiredToast = ({ toastId }: SessionExpiredToastProps) => { - const { login } = useGoogleAuth(); + const { login } = useGoogleAuth({ googleAuthIntent: "reconnect" }); const handleReconnect = () => { login(); diff --git a/packages/web/src/components/oauth/ouath.types.ts b/packages/web/src/components/oauth/ouath.types.ts index 04c745cde..eea6e7479 100644 --- a/packages/web/src/components/oauth/ouath.types.ts +++ b/packages/web/src/components/oauth/ouath.types.ts @@ -1,8 +1,10 @@ import { type CodeResponse } from "@react-oauth/google"; +import { type GoogleAuthIntent } from "@core/types/google-auth.types"; export interface SignInUpInput { thirdPartyId: string; clientType: "web"; + googleAuthIntent?: GoogleAuthIntent; redirectURIInfo: { redirectURIOnProviderDashboard: string; redirectURIQueryParams: Omit< From 1026ceca1eda2c54df1891182fdd67aa33f03964 Mon Sep 17 00:00:00 2001 From: Tyler Dane Date: Thu, 12 Mar 2026 20:38:15 -0700 Subject: [PATCH 24/24] feat(google-auth): refactor Google authentication flow and enhance reconnect handling - Renamed `reconnectGoogleForSession` to `repairGoogleConnection` for clarity in the authentication service. - Updated parameter names for consistency, changing `sessionUserId` to `compassUserId`. - Introduced `determineGoogleAuthMode` to assess the user's Google authentication state and return appropriate modes. - Enhanced tests to cover new authentication modes and ensure proper handling of reconnect scenarios. - Removed unused GoogleAuthIntent handling from various components, streamlining the authentication process. --- .../auth/schemas/reconnect-google.schemas.ts | 4 +- .../services/compass.auth.service.test.ts | 175 +++++++++++++++++- .../src/auth/services/compass.auth.service.ts | 101 ++++++++-- .../google.auth.success.service.test.ts | 61 +++++- .../google/google.auth.success.service.ts | 61 ++++-- .../middleware/supertokens.middleware.ts | 5 - .../supertokens.middleware.util.test.ts | 63 +------ .../middleware/supertokens.middleware.util.ts | 41 +--- packages/core/src/types/google-auth.types.ts | 1 - .../auth/hooks/oauth/useConnectGoogle.test.ts | 36 +--- .../src/auth/hooks/oauth/useConnectGoogle.ts | 7 +- .../auth/hooks/oauth/useGoogleAuth.test.ts | 53 +----- .../web/src/auth/hooks/oauth/useGoogleAuth.ts | 15 +- .../toast/session-expired.toast.test.tsx | 4 +- .../utils/toast/session-expired.toast.tsx | 4 +- .../web/src/components/oauth/ouath.types.ts | 2 - .../web/src/socket/hooks/useGcalSync.test.ts | 26 +++ packages/web/src/socket/hooks/useGcalSync.ts | 12 +- .../SocketProvider.interaction.test.tsx | 35 ++++ 19 files changed, 463 insertions(+), 243 deletions(-) delete mode 100644 packages/core/src/types/google-auth.types.ts diff --git a/packages/backend/src/auth/schemas/reconnect-google.schemas.ts b/packages/backend/src/auth/schemas/reconnect-google.schemas.ts index 49856fb41..4c4ad7560 100644 --- a/packages/backend/src/auth/schemas/reconnect-google.schemas.ts +++ b/packages/backend/src/auth/schemas/reconnect-google.schemas.ts @@ -8,12 +8,12 @@ export type ParsedReconnectGoogleParams = { }; export function parseReconnectGoogleParams( - sessionUserId: string, + compassUserId: string, gUser: TokenPayload, oAuthTokens: Pick, ): ParsedReconnectGoogleParams { const cUserId = zObjectId - .parse(sessionUserId, { error: () => "Invalid credentials" }) + .parse(compassUserId, { error: () => "Invalid credentials" }) .toString(); StringV4Schema.parse(gUser.sub, { error: () => "Invalid Google user ID" }); const refreshToken = StringV4Schema.parse(oAuthTokens.refresh_token, { diff --git a/packages/backend/src/auth/services/compass.auth.service.test.ts b/packages/backend/src/auth/services/compass.auth.service.test.ts index f08699698..e0cf20aeb 100644 --- a/packages/backend/src/auth/services/compass.auth.service.test.ts +++ b/packages/backend/src/auth/services/compass.auth.service.test.ts @@ -1,5 +1,8 @@ import { type Credentials } from "google-auth-library"; +import { ObjectId } from "mongodb"; import { faker } from "@faker-js/faker"; +import { Resource_Sync } from "@core/types/sync.types"; +import { WatchSchema } from "@core/types/watch.types"; import { UserDriver } from "@backend/__tests__/drivers/user.driver"; import { cleanupCollections, @@ -8,17 +11,145 @@ import { } from "@backend/__tests__/helpers/mock.db.setup"; import { initSupertokens } from "@backend/common/middleware/supertokens.middleware"; import mongoService from "@backend/common/services/mongo.service"; +import syncService from "@backend/sync/services/sync.service"; +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"; import compassAuthService from "./compass.auth.service"; +const buildGoogleSignInSuccess = (userId: string, googleId: string) => ({ + providerUser: UserDriver.generateGoogleUser({ sub: googleId }), + oAuthTokens: { + access_token: faker.internet.jwt(), + refresh_token: faker.string.uuid(), + } as Pick, + createdNewRecipeUser: false, + recipeUserId: userId, + loginMethodsLength: 1, + sessionUserId: null, +}); + +const createActiveWatch = async (userId: string, gCalendarId: string) => { + await mongoService.watch.insertOne( + WatchSchema.parse({ + _id: new ObjectId(), + user: userId, + resourceId: faker.string.uuid(), + expiration: new Date(Date.now() + 60_000), + gCalendarId, + createdAt: new Date(), + }), + ); +}; + describe("CompassAuthService", () => { beforeAll(initSupertokens); beforeEach(setupTestDb); beforeEach(cleanupCollections); afterAll(cleanupTestDb); - describe("reconnectGoogleForSession", () => { + describe("determineGoogleAuthMode", () => { + it("returns reconnect_repair when the stored refresh token is missing", async () => { + const user = await UserDriver.createUser({ + withGoogleRefreshToken: false, + }); + const success = buildGoogleSignInSuccess( + user._id.toString(), + user.google?.googleId ?? faker.string.uuid(), + ); + + const result = await compassAuthService.determineGoogleAuthMode(success); + + expect(result).toMatchObject({ + authMode: "reconnect_repair", + cUserId: user._id.toString(), + hasStoredRefreshTokenBefore: false, + isReconnectRepair: true, + }); + }); + + it("returns signin_incremental when the user has a healthy incremental sync", async () => { + const user = await UserDriver.createUser(); + const userId = user._id.toString(); + await updateSync(Resource_Sync.CALENDAR, userId, user.email, { + nextSyncToken: faker.string.uuid(), + }); + await updateSync(Resource_Sync.EVENTS, userId, user.email, { + nextSyncToken: faker.string.uuid(), + }); + await createActiveWatch(userId, Resource_Sync.CALENDAR); + await createActiveWatch(userId, user.email); + await userMetadataService.updateUserMetadata({ + userId, + data: { + sync: { importGCal: "completed", incrementalGCalSync: "completed" }, + }, + }); + + const result = await compassAuthService.determineGoogleAuthMode( + buildGoogleSignInSuccess( + userId, + user.google?.googleId ?? faker.string.uuid(), + ), + ); + + expect(result).toMatchObject({ + authMode: "signin_incremental", + cUserId: userId, + hasStoredRefreshTokenBefore: true, + isReconnectRepair: false, + }); + }); + + it("returns signup for a brand-new Google user", async () => { + const newUserId = faker.database.mongodbObjectId(); + const result = await compassAuthService.determineGoogleAuthMode({ + providerUser: UserDriver.generateGoogleUser(), + oAuthTokens: { + access_token: faker.internet.jwt(), + refresh_token: faker.string.uuid(), + }, + createdNewRecipeUser: true, + recipeUserId: newUserId, + loginMethodsLength: 1, + sessionUserId: null, + }); + + expect(result).toMatchObject({ + authMode: "signup", + cUserId: newUserId, + hasStoredRefreshTokenBefore: false, + isReconnectRepair: false, + }); + }); + + it("returns reconnect_repair when sync state is not incremental-ready", async () => { + const user = await UserDriver.createUser(); + const userId = user._id.toString(); + await userMetadataService.updateUserMetadata({ + userId, + data: { + sync: { importGCal: "completed", incrementalGCalSync: "completed" }, + }, + }); + + const result = await compassAuthService.determineGoogleAuthMode( + buildGoogleSignInSuccess( + userId, + user.google?.googleId ?? faker.string.uuid(), + ), + ); + + expect(result).toMatchObject({ + authMode: "reconnect_repair", + cUserId: userId, + hasStoredRefreshTokenBefore: true, + isReconnectRepair: true, + }); + }); + }); + + describe("repairGoogleConnection", () => { it("relinks Google to the current Compass user and schedules a full reimport", async () => { const user = await UserDriver.createUser(); const sessionUserId = user._id.toString(); @@ -36,7 +167,7 @@ describe("CompassAuthService", () => { await userService.pruneGoogleData(sessionUserId); - const result = await compassAuthService.reconnectGoogleForSession( + const result = await compassAuthService.repairGoogleConnection( sessionUserId, gUser, oAuthTokens, @@ -79,7 +210,7 @@ describe("CompassAuthService", () => { await userService.pruneGoogleData(sessionUserId); await expect( - compassAuthService.reconnectGoogleForSession( + compassAuthService.repairGoogleConnection( sessionUserId, gUser, oAuthTokens, @@ -93,4 +224,42 @@ describe("CompassAuthService", () => { restartSpy.mockRestore(); }); }); + + describe("googleSignin", () => { + it("queues a full repair instead of incremental sync when the user was revoked", async () => { + const user = await UserDriver.createUser(); + const userId = user._id.toString(); + const restartSpy = jest + .spyOn(userService, "restartGoogleCalendarSync") + .mockResolvedValue(); + const incrementalSpy = jest + .spyOn(syncService, "importIncremental") + .mockResolvedValue(undefined as never); + const oAuthTokens: Pick = { + access_token: faker.internet.jwt(), + refresh_token: faker.string.uuid(), + }; + + await userService.pruneGoogleData(userId); + + const result = await compassAuthService.googleSignin( + UserDriver.generateGoogleUser({ + sub: user.google?.googleId, + picture: faker.image.url(), + }), + oAuthTokens, + ); + + const metadata = await userMetadataService.fetchUserMetadata(userId); + + expect(result).toEqual({ cUserId: userId }); + expect(restartSpy).toHaveBeenCalledWith(userId); + expect(incrementalSpy).not.toHaveBeenCalled(); + expect(metadata.sync?.importGCal).toBe("restart"); + expect(metadata.sync?.incrementalGCalSync).toBe("restart"); + + restartSpy.mockRestore(); + incrementalSpy.mockRestore(); + }); + }); }); diff --git a/packages/backend/src/auth/services/compass.auth.service.ts b/packages/backend/src/auth/services/compass.auth.service.ts index 67c3e351e..dbbdc75e2 100644 --- a/packages/backend/src/auth/services/compass.auth.service.ts +++ b/packages/backend/src/auth/services/compass.auth.service.ts @@ -6,9 +6,12 @@ import { mapCompassUserToEmailSubscriber } from "@core/mappers/subscriber/map.su import { StringV4Schema, zObjectId } from "@core/types/type.utils"; import { parseReconnectGoogleParams } from "@backend/auth/schemas/reconnect-google.schemas"; import GoogleAuthService from "@backend/auth/services/google/google.auth.service"; +import { + type GoogleAuthDecision, + type GoogleSignInSuccess, +} from "@backend/auth/services/google/google.auth.success.service"; import { ENV } from "@backend/common/constants/env.constants"; import { isMissingUserTagId } from "@backend/common/constants/env.util"; -import { error } from "@backend/common/errors/handlers/error.handler"; import { SyncError } from "@backend/common/errors/sync/sync.errors"; import mongoService from "@backend/common/services/mongo.service"; import EmailService from "@backend/email/email.service"; @@ -31,26 +34,80 @@ class CompassAuthService { }); }; - determineAuthMethod = async (gUserId: string) => { - const user = await findCompassUserBy("google.googleId", gUserId); + private assessGoogleConnection = async (userId: string) => { + const user = await findCompassUserBy("_id", userId); if (!user) { - return { authMethod: "signup", user: null }; + throw new Error( + `Could not resolve Compass user for Google auth: ${userId}`, + ); } - const userId = user._id.toString(); + const hasStoredRefreshTokenBefore = Boolean(user.google?.gRefreshToken); const sync = await getSync({ userId }); - if (!sync) { - throw error( - SyncError.NoSyncRecordForUser, - "Did not verify sync record for user", + const isIncrementalReady = Boolean(sync && canDoIncrementalSync(sync)); + const googleMetadata = + await userMetadataService.assessGoogleMetadata(userId); + const isHealthy = + googleMetadata.connectionStatus === "connected" && + googleMetadata.syncStatus === "healthy"; + + return { + cUserId: userId, + hasStoredRefreshTokenBefore, + isHealthy, + isIncrementalReady, + needsRepair: + !hasStoredRefreshTokenBefore || !isIncrementalReady || !isHealthy, + }; + }; + + determineGoogleAuthMode = async ( + success: GoogleSignInSuccess, + ): Promise => { + const { + createdNewRecipeUser, + loginMethodsLength, + recipeUserId, + providerUser, + } = success; + const isNewUser = createdNewRecipeUser && loginMethodsLength === 1; + + if (isNewUser) { + return { + authMode: "signup", + cUserId: recipeUserId, + hasStoredRefreshTokenBefore: false, + hasSession: success.sessionUserId !== null, + isReconnectRepair: false, + }; + } + + const googleUserId = StringV4Schema.parse(providerUser.sub, { + error: () => "Invalid Google user ID", + }); + const existingUser = + (await findCompassUserBy("_id", recipeUserId)) ?? + (await findCompassUserBy("google.googleId", googleUserId)); + + if (!existingUser) { + throw new Error( + `Could not resolve Compass user for Google auth: ${recipeUserId}`, ); } - const canLogin = canDoIncrementalSync(sync); - const authMethod = user && canLogin ? "login" : "signup"; + const cUserId = existingUser._id.toString(); + const assessment = await this.assessGoogleConnection(cUserId); - return { authMethod, user }; + return { + authMode: assessment.needsRepair + ? "reconnect_repair" + : "signin_incremental", + cUserId, + hasStoredRefreshTokenBefore: assessment.hasStoredRefreshTokenBefore, + hasSession: success.sessionUserId !== null, + isReconnectRepair: assessment.needsRepair, + }; }; createSessionForUser = async (cUserId: string) => { @@ -126,8 +183,8 @@ class CompassAuthService { return user; } - async reconnectGoogleForSession( - sessionUserId: string, + async repairGoogleConnection( + compassUserId: string, gUser: TokenPayload, oAuthTokens: Pick, ) { @@ -135,7 +192,7 @@ class CompassAuthService { cUserId, gUser: validatedGUser, refreshToken, - } = parseReconnectGoogleParams(sessionUserId, gUser, oAuthTokens); + } = parseReconnectGoogleParams(compassUserId, gUser, oAuthTokens); await userService.reconnectGoogleCredentials( cUserId, @@ -181,6 +238,20 @@ class CompassAuthService { const cUserId = zObjectId .parse(user?._id, { error: () => "Invalid credentials" }) .toString(); + const assessment = await this.assessGoogleConnection(cUserId); + + if (assessment.needsRepair) { + await userMetadataService.updateUserMetadata({ + userId: cUserId, + data: { + sync: { importGCal: "restart", incrementalGCalSync: "restart" }, + }, + }); + + this.restartGoogleCalendarSyncInBackground(cUserId); + + return { cUserId }; + } // start incremental sync - do not await const gAuthClient = new GoogleAuthService(); diff --git a/packages/backend/src/auth/services/google/google.auth.success.service.test.ts b/packages/backend/src/auth/services/google/google.auth.success.service.test.ts index 40dfe39cd..42c52bc54 100644 --- a/packages/backend/src/auth/services/google/google.auth.success.service.test.ts +++ b/packages/backend/src/auth/services/google/google.auth.success.service.test.ts @@ -1,6 +1,7 @@ import { type Credentials, type TokenPayload } from "google-auth-library"; import { faker } from "@faker-js/faker"; import { + type GoogleAuthDecision, type GoogleSignInSuccess, type GoogleSignInSuccessAuthService, handleGoogleAuth, @@ -26,12 +27,22 @@ function makeOAuthTokens(): Pick< } function createMockAuthService(): GoogleSignInSuccessAuthService & { - reconnectGoogleForSession: jest.Mock; + determineGoogleAuthMode: jest.Mock; + repairGoogleConnection: jest.Mock; googleSignup: jest.Mock; googleSignin: jest.Mock; } { + const defaultDecision: GoogleAuthDecision = { + authMode: "signin_incremental", + cUserId: faker.database.mongodbObjectId(), + hasStoredRefreshTokenBefore: true, + hasSession: false, + isReconnectRepair: false, + }; + return { - reconnectGoogleForSession: jest + determineGoogleAuthMode: jest.fn().mockResolvedValue(defaultDecision), + repairGoogleConnection: jest .fn() .mockResolvedValue({ cUserId: "reconnect-id" }), googleSignup: jest.fn().mockResolvedValue({ cUserId: "signup-id" }), @@ -41,11 +52,18 @@ function createMockAuthService(): GoogleSignInSuccessAuthService & { describe("handleGoogleSignInSuccess", () => { describe("reconnect path", () => { - it("calls reconnectGoogleForSession when sessionUserId is set", async () => { + it("calls repairGoogleConnection when auth mode is reconnect_repair", async () => { const authService = createMockAuthService(); const providerUser = makeProviderUser(); const oAuthTokens = makeOAuthTokens(); const sessionUserId = faker.database.mongodbObjectId(); + authService.determineGoogleAuthMode.mockResolvedValue({ + authMode: "reconnect_repair", + cUserId: sessionUserId, + hasStoredRefreshTokenBefore: false, + hasSession: true, + isReconnectRepair: true, + }); const success: GoogleSignInSuccess = { providerUser, @@ -58,8 +76,9 @@ describe("handleGoogleSignInSuccess", () => { await handleGoogleAuth(success, authService); - expect(authService.reconnectGoogleForSession).toHaveBeenCalledTimes(1); - expect(authService.reconnectGoogleForSession).toHaveBeenCalledWith( + expect(authService.determineGoogleAuthMode).toHaveBeenCalledWith(success); + expect(authService.repairGoogleConnection).toHaveBeenCalledTimes(1); + expect(authService.repairGoogleConnection).toHaveBeenCalledWith( sessionUserId, providerUser, oAuthTokens, @@ -75,6 +94,13 @@ describe("handleGoogleSignInSuccess", () => { const providerUser = makeProviderUser(); const oAuthTokens = makeOAuthTokens(); const recipeUserId = faker.database.mongodbObjectId(); + authService.determineGoogleAuthMode.mockResolvedValue({ + authMode: "signup", + cUserId: recipeUserId, + hasStoredRefreshTokenBefore: false, + hasSession: false, + isReconnectRepair: false, + }); const success: GoogleSignInSuccess = { providerUser, @@ -93,12 +119,19 @@ describe("handleGoogleSignInSuccess", () => { oAuthTokens.refresh_token, recipeUserId, ); - expect(authService.reconnectGoogleForSession).not.toHaveBeenCalled(); + expect(authService.repairGoogleConnection).not.toHaveBeenCalled(); expect(authService.googleSignin).not.toHaveBeenCalled(); }); it("throws when refresh_token is missing for new user", async () => { const authService = createMockAuthService(); + authService.determineGoogleAuthMode.mockResolvedValue({ + authMode: "signup", + cUserId: faker.database.mongodbObjectId(), + hasStoredRefreshTokenBefore: false, + hasSession: false, + isReconnectRepair: false, + }); const success: GoogleSignInSuccess = { providerUser: makeProviderUser(), oAuthTokens: { access_token: faker.internet.jwt() }, @@ -121,6 +154,13 @@ describe("handleGoogleSignInSuccess", () => { const authService = createMockAuthService(); const providerUser = makeProviderUser(); const oAuthTokens = makeOAuthTokens(); + authService.determineGoogleAuthMode.mockResolvedValue({ + authMode: "signin_incremental", + cUserId: faker.database.mongodbObjectId(), + hasStoredRefreshTokenBefore: true, + hasSession: false, + isReconnectRepair: false, + }); const success: GoogleSignInSuccess = { providerUser, @@ -138,7 +178,7 @@ describe("handleGoogleSignInSuccess", () => { providerUser, oAuthTokens, ); - expect(authService.reconnectGoogleForSession).not.toHaveBeenCalled(); + expect(authService.repairGoogleConnection).not.toHaveBeenCalled(); expect(authService.googleSignup).not.toHaveBeenCalled(); }); @@ -146,6 +186,13 @@ describe("handleGoogleSignInSuccess", () => { const authService = createMockAuthService(); const providerUser = makeProviderUser(); const oAuthTokens = makeOAuthTokens(); + authService.determineGoogleAuthMode.mockResolvedValue({ + authMode: "signin_incremental", + cUserId: faker.database.mongodbObjectId(), + hasStoredRefreshTokenBefore: true, + hasSession: false, + isReconnectRepair: false, + }); const success: GoogleSignInSuccess = { providerUser, diff --git a/packages/backend/src/auth/services/google/google.auth.success.service.ts b/packages/backend/src/auth/services/google/google.auth.success.service.ts index 0078a9479..88e5afd1a 100644 --- a/packages/backend/src/auth/services/google/google.auth.success.service.ts +++ b/packages/backend/src/auth/services/google/google.auth.success.service.ts @@ -1,4 +1,20 @@ import { type Credentials, type TokenPayload } from "google-auth-library"; +import { Logger } from "@core/logger/winston.logger"; + +const logger = Logger("app:google.auth.success.service"); + +export type GoogleAuthMode = + | "signup" + | "signin_incremental" + | "reconnect_repair"; + +export type GoogleAuthDecision = { + authMode: GoogleAuthMode; + cUserId: string | null; + hasStoredRefreshTokenBefore: boolean; + hasSession: boolean; + isReconnectRepair: boolean; +}; export type GoogleSignInSuccess = { providerUser: TokenPayload; @@ -10,8 +26,11 @@ export type GoogleSignInSuccess = { }; export interface GoogleSignInSuccessAuthService { - reconnectGoogleForSession( - sessionUserId: string, + determineGoogleAuthMode( + success: GoogleSignInSuccess, + ): Promise; + repairGoogleConnection( + compassUserId: string, gUser: TokenPayload, oAuthTokens: Pick, ): Promise<{ cUserId: string }>; @@ -36,21 +55,22 @@ export async function handleGoogleAuth( createdNewRecipeUser, recipeUserId, loginMethodsLength, - sessionUserId, } = success; + const decision = await authService.determineGoogleAuthMode(success); - if (sessionUserId !== null) { - await authService.reconnectGoogleForSession( - sessionUserId, - providerUser, - oAuthTokens, - ); - return; - } - - const isNewUser = createdNewRecipeUser && loginMethodsLength === 1; + logger.info( + `Resolved Google auth mode: ${JSON.stringify({ + auth_mode: decision.authMode, + createdNewRecipeUser, + hasStoredRefreshTokenBefore: decision.hasStoredRefreshTokenBefore, + hasSession: decision.hasSession, + isReconnectRepair: decision.isReconnectRepair, + recipeUserId, + loginMethodsLength, + })}`, + ); - if (isNewUser) { + if (decision.authMode === "signup") { const refreshToken = oAuthTokens.refresh_token; if (!refreshToken) { throw new Error("Refresh token expected for new user sign-up"); @@ -59,5 +79,18 @@ export async function handleGoogleAuth( return; } + if (decision.authMode === "reconnect_repair") { + if (!decision.cUserId) { + throw new Error("Compass user ID expected for Google reconnect repair"); + } + + await authService.repairGoogleConnection( + decision.cUserId, + providerUser, + oAuthTokens, + ); + return; + } + await authService.googleSignin(providerUser, oAuthTokens); } diff --git a/packages/backend/src/common/middleware/supertokens.middleware.ts b/packages/backend/src/common/middleware/supertokens.middleware.ts index d27634602..e9ba4e2f1 100644 --- a/packages/backend/src/common/middleware/supertokens.middleware.ts +++ b/packages/backend/src/common/middleware/supertokens.middleware.ts @@ -20,7 +20,6 @@ import { type CreateGoogleSignInResponse, type ThirdPartySignInUpInput, createGoogleSignInSuccess, - getGoogleAuthIntent, } from "@backend/common/middleware/supertokens.middleware.util"; import mongoService from "@backend/common/services/mongo.service"; import syncService from "@backend/sync/services/sync.service"; @@ -129,12 +128,8 @@ export const initSupertokens = () => { const response = await originalImplementation.signInUpPOST(input); - const body = (await input.options.req.getJSONBody()) as { - googleAuthIntent?: unknown; - }; const success = createGoogleSignInSuccess( response as CreateGoogleSignInResponse, - getGoogleAuthIntent(body?.googleAuthIntent), input.session?.getUserId() ?? null, ); diff --git a/packages/backend/src/common/middleware/supertokens.middleware.util.test.ts b/packages/backend/src/common/middleware/supertokens.middleware.util.test.ts index fdc968ef2..9b4034c57 100644 --- a/packages/backend/src/common/middleware/supertokens.middleware.util.test.ts +++ b/packages/backend/src/common/middleware/supertokens.middleware.util.test.ts @@ -1,61 +1,8 @@ import { type TokenPayload } from "google-auth-library"; import { faker } from "@faker-js/faker"; -import { - createGoogleSignInSuccess, - resolveGoogleSessionUserId, -} from "@backend/common/middleware/supertokens.middleware.util"; +import { createGoogleSignInSuccess } from "@backend/common/middleware/supertokens.middleware.util"; describe("supertokens.middleware.util", () => { - describe("resolveGoogleSessionUserId", () => { - it("prefers the current session when one exists", () => { - const sessionUserId = faker.database.mongodbObjectId(); - const recipeUserId = faker.database.mongodbObjectId(); - - expect( - resolveGoogleSessionUserId({ - sessionUserId, - googleAuthIntent: "reconnect", - createdNewRecipeUser: false, - recipeUserId, - }), - ).toBe(sessionUserId); - }); - - it("uses the recipe user id for reconnects without a session", () => { - const recipeUserId = faker.database.mongodbObjectId(); - - expect( - resolveGoogleSessionUserId({ - sessionUserId: null, - googleAuthIntent: "reconnect", - createdNewRecipeUser: false, - recipeUserId, - }), - ).toBe(recipeUserId); - }); - - it("keeps normal returning users on the sign-in path without reconnect intent", () => { - expect( - resolveGoogleSessionUserId({ - sessionUserId: null, - createdNewRecipeUser: false, - recipeUserId: faker.database.mongodbObjectId(), - }), - ).toBeNull(); - }); - - it("does not force reconnect behavior for new users", () => { - expect( - resolveGoogleSessionUserId({ - sessionUserId: null, - googleAuthIntent: "reconnect", - createdNewRecipeUser: true, - recipeUserId: faker.database.mongodbObjectId(), - }), - ).toBeNull(); - }); - }); - describe("createGoogleSignInSuccess", () => { it("returns null for non-OK responses", () => { expect( @@ -65,8 +12,9 @@ describe("supertokens.middleware.util", () => { ).toBeNull(); }); - it("embeds reconnect fallback user id into the auth success payload", () => { + it("preserves the current session user id in the auth success payload", () => { const recipeUserId = faker.database.mongodbObjectId(); + const sessionUserId = faker.database.mongodbObjectId(); const success = createGoogleSignInSuccess( { status: "OK", @@ -86,14 +34,13 @@ describe("supertokens.middleware.util", () => { loginMethods: [{}], }, } as Parameters[0], - "reconnect", - null, + sessionUserId, ); expect(success).toMatchObject({ createdNewRecipeUser: false, recipeUserId, - sessionUserId: recipeUserId, + sessionUserId, loginMethodsLength: 1, }); }); diff --git a/packages/backend/src/common/middleware/supertokens.middleware.util.ts b/packages/backend/src/common/middleware/supertokens.middleware.util.ts index beb365519..698cf1f6a 100644 --- a/packages/backend/src/common/middleware/supertokens.middleware.util.ts +++ b/packages/backend/src/common/middleware/supertokens.middleware.util.ts @@ -1,6 +1,5 @@ import { type Credentials, type TokenPayload } from "google-auth-library"; import type { APIInterface } from "supertokens-node/recipe/thirdparty/types"; -import { type GoogleAuthIntent } from "@core/types/google-auth.types"; import type { GoogleSignInSuccess } from "@backend/auth/services/google/google.auth.success.service"; type ThirdPartySignInUpPost = NonNullable; @@ -20,41 +19,8 @@ export type CreateGoogleSignInResponse = | { status: Exclude } | GoogleThirdPartySignInUpSuccess; -export function getGoogleAuthIntent( - value: unknown, -): GoogleAuthIntent | undefined { - if (value === "connect" || value === "reconnect") { - return value; - } - - return undefined; -} - -export function resolveGoogleSessionUserId({ - sessionUserId, - googleAuthIntent, - createdNewRecipeUser, - recipeUserId, -}: { - sessionUserId: string | null; - googleAuthIntent?: GoogleAuthIntent; - createdNewRecipeUser: boolean; - recipeUserId: string; -}): string | null { - if (sessionUserId) { - return sessionUserId; - } - - if (googleAuthIntent === "reconnect" && !createdNewRecipeUser) { - return recipeUserId; - } - - return null; -} - export function createGoogleSignInSuccess( response: CreateGoogleSignInResponse, - googleAuthIntent?: GoogleAuthIntent, sessionUserId: string | null = null, ): GoogleSignInSuccess | null { if (response.status !== "OK") return null; @@ -65,11 +31,6 @@ export function createGoogleSignInSuccess( createdNewRecipeUser: response.createdNewRecipeUser, recipeUserId: response.user.id, loginMethodsLength: response.user.loginMethods.length, - sessionUserId: resolveGoogleSessionUserId({ - sessionUserId, - googleAuthIntent, - createdNewRecipeUser: response.createdNewRecipeUser, - recipeUserId: response.user.id, - }), + sessionUserId, }; } diff --git a/packages/core/src/types/google-auth.types.ts b/packages/core/src/types/google-auth.types.ts deleted file mode 100644 index f5b13809b..000000000 --- a/packages/core/src/types/google-auth.types.ts +++ /dev/null @@ -1 +0,0 @@ -export type GoogleAuthIntent = "connect" | "reconnect"; diff --git a/packages/web/src/auth/hooks/oauth/useConnectGoogle.test.ts b/packages/web/src/auth/hooks/oauth/useConnectGoogle.test.ts index 0e154fd25..9f6aad3d1 100644 --- a/packages/web/src/auth/hooks/oauth/useConnectGoogle.test.ts +++ b/packages/web/src/auth/hooks/oauth/useConnectGoogle.test.ts @@ -69,9 +69,7 @@ describe("useConnectGoogle", () => { it("returns checking state when metadata is still loading", () => { const { result } = renderHook(() => useConnectGoogle()); - expect(mockUseGoogleAuth).toHaveBeenCalledWith({ - googleAuthIntent: undefined, - }); + expect(mockUseGoogleAuth).toHaveBeenCalledWith(); expect(result.current.commandAction.label).toBe( "Checking Google Calendar…", ); @@ -107,9 +105,7 @@ describe("useConnectGoogle", () => { const { result } = renderHook(() => useConnectGoogle()); - expect(mockUseGoogleAuth).toHaveBeenCalledWith({ - googleAuthIntent: undefined, - }); + expect(mockUseGoogleAuth).toHaveBeenCalledWith(); expect(result.current.commandAction.label).toBe("Connect Google Calendar"); expect(result.current.commandAction.isDisabled).toBe(false); expect(result.current.sidebarStatus.icon).toBe("CloudArrowUpIcon"); @@ -143,9 +139,7 @@ describe("useConnectGoogle", () => { const { result } = renderHook(() => useConnectGoogle()); - expect(mockUseGoogleAuth).toHaveBeenCalledWith({ - googleAuthIntent: undefined, - }); + expect(mockUseGoogleAuth).toHaveBeenCalledWith(); expect(result.current.commandAction.label).toBe( "Google Calendar Connected", ); @@ -180,9 +174,7 @@ describe("useConnectGoogle", () => { const { result } = renderHook(() => useConnectGoogle()); - expect(mockUseGoogleAuth).toHaveBeenCalledWith({ - googleAuthIntent: "reconnect", - }); + expect(mockUseGoogleAuth).toHaveBeenCalledWith(); expect(result.current.commandAction.label).toBe( "Reconnect Google Calendar", ); @@ -220,9 +212,7 @@ describe("useConnectGoogle", () => { const { result } = renderHook(() => useConnectGoogle()); - expect(mockUseGoogleAuth).toHaveBeenCalledWith({ - googleAuthIntent: undefined, - }); + expect(mockUseGoogleAuth).toHaveBeenCalledWith(); expect(result.current.commandAction.label).toBe("Syncing Google Calendar…"); expect(result.current.commandAction.isDisabled).toBe(true); expect(result.current.commandAction.onSelect).toBeUndefined(); @@ -255,9 +245,7 @@ describe("useConnectGoogle", () => { const { result } = renderHook(() => useConnectGoogle()); - expect(mockUseGoogleAuth).toHaveBeenCalledWith({ - googleAuthIntent: undefined, - }); + expect(mockUseGoogleAuth).toHaveBeenCalledWith(); expect(result.current.commandAction.label).toBe("Repair Google Calendar"); expect(result.current.commandAction.isDisabled).toBe(false); expect(result.current.sidebarStatus.icon).toBe("CloudWarningIcon"); @@ -316,9 +304,7 @@ describe("useConnectGoogle", () => { const { result } = renderHook(() => useConnectGoogle()); - expect(mockUseGoogleAuth).toHaveBeenCalledWith({ - googleAuthIntent: undefined, - }); + expect(mockUseGoogleAuth).toHaveBeenCalledWith(); expect(result.current.commandAction.isDisabled).toBe(true); expect(result.current.sidebarStatus.icon).toBe("SpinnerIcon"); expect(result.current.sidebarStatus.isDisabled).toBe(true); @@ -349,9 +335,7 @@ describe("useConnectGoogle", () => { const { result } = renderHook(() => useConnectGoogle()); - expect(mockUseGoogleAuth).toHaveBeenCalledWith({ - googleAuthIntent: "reconnect", - }); + expect(mockUseGoogleAuth).toHaveBeenCalledWith(); expect(result.current.commandAction.label).toBe( "Reconnect Google Calendar", ); @@ -382,9 +366,7 @@ describe("useConnectGoogle", () => { const { result } = renderHook(() => useConnectGoogle()); - expect(mockUseGoogleAuth).toHaveBeenCalledWith({ - googleAuthIntent: undefined, - }); + expect(mockUseGoogleAuth).toHaveBeenCalledWith(); expect(result.current.commandAction.label).toBe("Connect Google Calendar"); expect(result.current.sidebarStatus.icon).toBe("CloudArrowUpIcon"); }); diff --git a/packages/web/src/auth/hooks/oauth/useConnectGoogle.ts b/packages/web/src/auth/hooks/oauth/useConnectGoogle.ts index bd1b19fc3..2589a392b 100644 --- a/packages/web/src/auth/hooks/oauth/useConnectGoogle.ts +++ b/packages/web/src/auth/hooks/oauth/useConnectGoogle.ts @@ -201,13 +201,10 @@ export const useConnectGoogle = () => { ); const connectionStatus = googleMetadata?.connectionStatus ?? "not_connected"; const syncStatus = googleMetadata?.syncStatus ?? "none"; - const { login } = useGoogleAuth({ - googleAuthIntent: - connectionStatus === "reconnect_required" ? "reconnect" : undefined, - }); + const { login } = useGoogleAuth(); const onOpenGoogleAuth = useCallback(() => { - login(); + void login(); dispatch(settingsSlice.actions.closeCmdPalette()); }, [dispatch, login]); diff --git a/packages/web/src/auth/hooks/oauth/useGoogleAuth.test.ts b/packages/web/src/auth/hooks/oauth/useGoogleAuth.test.ts index 1797467e5..d7d83857e 100644 --- a/packages/web/src/auth/hooks/oauth/useGoogleAuth.test.ts +++ b/packages/web/src/auth/hooks/oauth/useGoogleAuth.test.ts @@ -1,3 +1,4 @@ +import { toast } from "react-toastify"; import { renderHook, waitFor } from "@testing-library/react"; import { authenticate, @@ -9,6 +10,7 @@ import { refreshUserMetadata } from "@web/auth/session/user-metadata.util"; import { markUserAsAuthenticated } from "@web/auth/state/auth.state.util"; import { useGoogleLogin } from "@web/components/oauth/google/useGoogleLogin"; import { type SignInUpInput } from "@web/components/oauth/ouath.types"; +import { useAppDispatch } from "@web/store/store.hooks"; // Mock dependencies jest.mock("@web/auth/google/google.auth.util"); @@ -43,14 +45,14 @@ const mockUseGoogleLogin = useGoogleLogin as jest.MockedFunction< const mockRefreshUserMetadata = refreshUserMetadata as jest.MockedFunction< typeof refreshUserMetadata >; -const storeHooksMock = jest.requireMock("@web/store/store.hooks") as { - useAppDispatch: jest.Mock; -}; -const mockUseAppDispatch = storeHooksMock.useAppDispatch; +const mockUseAppDispatch = useAppDispatch as jest.MockedFunction< + typeof useAppDispatch +>; const mockMarkUserAsAuthenticated = markUserAsAuthenticated as jest.MockedFunction< typeof markUserAsAuthenticated >; +const mockToast = jest.mocked(toast); describe("useGoogleAuth", () => { const mockSetAuthenticated = jest.fn(); @@ -152,44 +154,6 @@ describe("useGoogleAuth", () => { expect(mockRefreshUserMetadata).toHaveBeenCalledTimes(1); }); - it("passes reconnect intent through authentication when requested", async () => { - let onSuccessCallback: ((data: SignInUpInput) => Promise) | undefined; - - mockUseGoogleLogin.mockImplementation(({ onSuccess }) => { - onSuccessCallback = onSuccess; - return { - login: mockLogin, - loading: false, - data: null, - }; - }); - - renderHook(() => useGoogleAuth({ googleAuthIntent: "reconnect" })); - - if (onSuccessCallback) { - await onSuccessCallback({ - clientType: "web", - thirdPartyId: "google", - redirectURIInfo: { - redirectURIOnProviderDashboard: "", - redirectURIQueryParams: { - code: "test-auth-code", - scope: "email profile", - state: undefined, - }, - }, - }); - } - - await waitFor(() => { - expect(mockAuthenticate).toHaveBeenCalledWith( - expect.objectContaining({ - googleAuthIntent: "reconnect", - }), - ); - }); - }); - describe("onStart callback", () => { it("shows overlay immediately when login starts and clears session-expired toast", () => { mockUseGoogleLogin.mockReturnValue({ @@ -201,7 +165,7 @@ describe("useGoogleAuth", () => { const { result } = renderHook(() => useGoogleAuth()); // Simulate login start - result.current.login(); + void result.current.login(); expect(mockDispatchFn).toHaveBeenCalledWith( expect.objectContaining({ type: "auth/startAuthenticating" }), @@ -212,8 +176,7 @@ describe("useGoogleAuth", () => { payload: true, }), ); - const { toast } = jest.requireMock("react-toastify"); - expect(toast.dismiss).toHaveBeenCalledWith("session-expired-api"); + expect(mockToast.dismiss).toHaveBeenCalledWith("session-expired-api"); }); }); diff --git a/packages/web/src/auth/hooks/oauth/useGoogleAuth.ts b/packages/web/src/auth/hooks/oauth/useGoogleAuth.ts index f3c8b06ba..a5fcc3684 100644 --- a/packages/web/src/auth/hooks/oauth/useGoogleAuth.ts +++ b/packages/web/src/auth/hooks/oauth/useGoogleAuth.ts @@ -1,6 +1,5 @@ import { batch } from "react-redux"; import { toast } from "react-toastify"; -import { type GoogleAuthIntent } from "@core/types/google-auth.types"; import { isGooglePopupClosedError } from "@web/auth/google/google-oauth-error.util"; import { authenticate, @@ -15,7 +14,6 @@ import { SESSION_EXPIRED_TOAST_ID, dismissErrorToast, } from "@web/common/utils/toast/error-toast.util"; -import { type SignInUpInput } from "@web/components/oauth/ouath.types"; import { authError, authSuccess, @@ -28,14 +26,9 @@ import { } from "@web/ducks/events/slices/sync.slice"; import { useAppDispatch } from "@web/store/store.hooks"; -interface UseGoogleAuthOptions { - googleAuthIntent?: GoogleAuthIntent; -} - -export function useGoogleAuth(options: UseGoogleAuthOptions = {}) { +export function useGoogleAuth() { const dispatch = useAppDispatch(); const { setAuthenticated } = useSession(); - const { googleAuthIntent } = options; const googleLogin = useGoogleAuthWithOverlay({ onStart: () => { @@ -46,11 +39,7 @@ export function useGoogleAuth(options: UseGoogleAuthOptions = {}) { }, onSuccess: async (data) => { try { - const authPayload: SignInUpInput = - googleAuthIntent === "reconnect" - ? { ...data, googleAuthIntent } - : data; - const authResult = await authenticate(authPayload); + const authResult = await authenticate(data); if (!authResult.success) { console.error(authResult.error); dispatch( diff --git a/packages/web/src/common/utils/toast/session-expired.toast.test.tsx b/packages/web/src/common/utils/toast/session-expired.toast.test.tsx index 4abb50ccb..fab6c8ae9 100644 --- a/packages/web/src/common/utils/toast/session-expired.toast.test.tsx +++ b/packages/web/src/common/utils/toast/session-expired.toast.test.tsx @@ -26,9 +26,7 @@ describe("SessionExpiredToast", () => { it("renders session-expired message and reconnect button", () => { render(); - expect(mockUseGoogleAuth).toHaveBeenCalledWith({ - googleAuthIntent: "reconnect", - }); + expect(mockUseGoogleAuth).toHaveBeenCalledWith(); expect( screen.getByText("Google Calendar connection expired. Please reconnect."), ).toBeInTheDocument(); diff --git a/packages/web/src/common/utils/toast/session-expired.toast.tsx b/packages/web/src/common/utils/toast/session-expired.toast.tsx index eb6d1f52a..1df79103b 100644 --- a/packages/web/src/common/utils/toast/session-expired.toast.tsx +++ b/packages/web/src/common/utils/toast/session-expired.toast.tsx @@ -6,10 +6,10 @@ interface SessionExpiredToastProps { } export const SessionExpiredToast = ({ toastId }: SessionExpiredToastProps) => { - const { login } = useGoogleAuth({ googleAuthIntent: "reconnect" }); + const { login } = useGoogleAuth(); const handleReconnect = () => { - login(); + void login(); toast.dismiss(toastId); }; diff --git a/packages/web/src/components/oauth/ouath.types.ts b/packages/web/src/components/oauth/ouath.types.ts index eea6e7479..04c745cde 100644 --- a/packages/web/src/components/oauth/ouath.types.ts +++ b/packages/web/src/components/oauth/ouath.types.ts @@ -1,10 +1,8 @@ import { type CodeResponse } from "@react-oauth/google"; -import { type GoogleAuthIntent } from "@core/types/google-auth.types"; export interface SignInUpInput { thirdPartyId: string; clientType: "web"; - googleAuthIntent?: GoogleAuthIntent; redirectURIInfo: { redirectURIOnProviderDashboard: string; redirectURIQueryParams: Omit< diff --git a/packages/web/src/socket/hooks/useGcalSync.test.ts b/packages/web/src/socket/hooks/useGcalSync.test.ts index df24eee4e..013ea6486 100644 --- a/packages/web/src/socket/hooks/useGcalSync.test.ts +++ b/packages/web/src/socket/hooks/useGcalSync.test.ts @@ -393,6 +393,32 @@ describe("useGcalSync", () => { expect(importGCalSlice.actions.setImportResults).not.toHaveBeenCalled(); expect(importGCalSlice.actions.setImportError).not.toHaveBeenCalled(); }); + + it("clears pending import when reconnect metadata settles in attention", () => { + awaitingValue = true; + let metadataHandler: ((metadata: unknown) => void) | undefined; + (socket.on as jest.Mock).mockImplementation((event, handler) => { + if (event === USER_METADATA) { + metadataHandler = handler; + } + }); + + renderHook(() => useGcalSync()); + + metadataHandler?.({ + sync: { importGCal: "completed" }, + google: { connectionStatus: "connected", syncStatus: "attention" }, + }); + + expect(mockDispatch).toHaveBeenCalledWith( + importGCalSlice.actions.importing(false), + ); + expect(mockDispatch).toHaveBeenCalledWith( + importGCalSlice.actions.setImportError( + "Google Calendar still needs repair after reconnect.", + ), + ); + }); }); describe("import flow interaction", () => { diff --git a/packages/web/src/socket/hooks/useGcalSync.ts b/packages/web/src/socket/hooks/useGcalSync.ts index 18e452bc8..02ca49684 100644 --- a/packages/web/src/socket/hooks/useGcalSync.ts +++ b/packages/web/src/socket/hooks/useGcalSync.ts @@ -83,11 +83,21 @@ export const useGcalSync = () => { const importStatus = metadata.sync?.importGCal; const isBackendImporting = importStatus === "importing"; const shouldAutoImport = importStatus === "restart"; + const isAttentionAfterReconnect = + metadata.google?.connectionStatus === "connected" && + metadata.google?.syncStatus === "attention"; dispatch(userMetadataSlice.actions.set(metadata)); if (isImportPendingRef.current) { - if (isBackendImporting) { + if (isAttentionAfterReconnect) { + dispatch(importGCalSlice.actions.importing(false)); + dispatch( + importGCalSlice.actions.setImportError( + "Google Calendar still needs repair after reconnect.", + ), + ); + } else if (isBackendImporting) { dispatch(importGCalSlice.actions.importing(true)); } else if (importStatus === "completed") { dispatch(importGCalSlice.actions.importing(false)); diff --git a/packages/web/src/socket/provider/SocketProvider.interaction.test.tsx b/packages/web/src/socket/provider/SocketProvider.interaction.test.tsx index c0b8fefcb..47bb597d3 100644 --- a/packages/web/src/socket/provider/SocketProvider.interaction.test.tsx +++ b/packages/web/src/socket/provider/SocketProvider.interaction.test.tsx @@ -514,6 +514,41 @@ describe("GCal Re-Authentication Flow", () => { ).not.toBeInTheDocument(); expect(store.getState().sync.importGCal.isImportPending).toBe(false); }); + + it("hides spinner and stores an error when reconnect lands in attention", async () => { + const store = createTestStore({ isImportPending: true, importing: true }); + + render( + + + + + , + ); + + await waitFor(() => { + expect(metadataCallback).toBeDefined(); + }); + + await act(async () => { + metadataCallback?.({ + sync: { importGCal: "completed" }, + google: { connectionStatus: "connected", syncStatus: "attention" }, + }); + }); + + await act(async () => { + jest.advanceTimersByTime(100); + }); + + expect( + screen.queryByText("Importing your Google Calendar events..."), + ).not.toBeInTheDocument(); + expect(store.getState().sync.importGCal.isImportPending).toBe(false); + expect(store.getState().sync.importGCal.importError).toBe( + "Google Calendar still needs repair after reconnect.", + ); + }); }); describe("Race condition handling (ref pattern)", () => {