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/.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 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` 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/sync.controller.driver.ts b/packages/backend/src/__tests__/drivers/sync.controller.driver.ts index 6dc0363cc..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, @@ -64,12 +33,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/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/__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/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 62e320604..e9ba4e2f1 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,13 @@ 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, +} 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 +111,32 @@ 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 success = createGoogleSignInSuccess( + response as CreateGoogleSignInResponse, + input.session?.getUserId() ?? null, + ); + + if (success) { + await handleGoogleAuth(success, compassAuthService); + } + return response; }, }; @@ -174,28 +198,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..9b4034c57 --- /dev/null +++ b/packages/backend/src/common/middleware/supertokens.middleware.util.test.ts @@ -0,0 +1,48 @@ +import { type TokenPayload } from "google-auth-library"; +import { faker } from "@faker-js/faker"; +import { createGoogleSignInSuccess } from "@backend/common/middleware/supertokens.middleware.util"; + +describe("supertokens.middleware.util", () => { + describe("createGoogleSignInSuccess", () => { + it("returns null for non-OK responses", () => { + expect( + createGoogleSignInSuccess({ + status: "SIGN_IN_UP_NOT_ALLOWED", + } as Parameters[0]), + ).toBeNull(); + }); + + 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", + 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], + sessionUserId, + ); + + expect(success).toMatchObject({ + createdNewRecipeUser: false, + 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 new file mode 100644 index 000000000..698cf1f6a --- /dev/null +++ b/packages/backend/src/common/middleware/supertokens.middleware.util.ts @@ -0,0 +1,36 @@ +import { type Credentials, type TokenPayload } from "google-auth-library"; +import type { APIInterface } from "supertokens-node/recipe/thirdparty/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 createGoogleSignInSuccess( + response: CreateGoogleSignInResponse, + 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, + }; +} 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..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, @@ -30,7 +31,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 +84,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({ @@ -252,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 45d8d59c2..a5a0db450 100644 --- a/packages/backend/src/sync/controllers/sync.controller.test.ts +++ b/packages/backend/src/sync/controllers/sync.controller.test.ts @@ -6,10 +6,13 @@ 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"; 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"; @@ -28,10 +31,10 @@ 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"; +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"; @@ -41,6 +44,77 @@ import userService from "@backend/user/services/user.service"; describe("SyncController", () => { const baseDriver = new BaseDriver(); const syncDriver = new SyncControllerDriver(baseDriver); + const importTimeoutMs = 7_000; + + interface ImportSummary { + status: "completed"; + eventsCount: number; + calendarsCount: number; + } + + 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( + 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: [ImportGCalEndPayload | 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 +132,21 @@ 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 importResult = (importEnd as { value: unknown })?.value as + | ImportGCalEndPayload + | undefined; + const parsed = parseImportResult(importResult); expect(parsed).toHaveProperty("eventsCount"); expect(parsed).toHaveProperty("calendarsCount"); @@ -169,12 +244,17 @@ 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 .spyOn(userService, "restartGoogleCalendarSync") - .mockResolvedValue(); + .mockImplementation(async () => { + await userMetadataService.updateUserMetadata({ + userId, + data: { sync: { importGCal: "importing" } }, + }); + }); const watch = await mongoService.watch.findOne({ user: userId, @@ -184,11 +264,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, }); @@ -215,12 +290,13 @@ describe("SyncController", () => { Status.NO_CONTENT, ); - expect(restartSpy).not.toHaveBeenCalled(); + 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"; @@ -248,7 +324,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 () => { @@ -500,7 +635,7 @@ describe("SyncController", () => { data: { sync: { importGCal: "restart" } }, }); - await syncDriver.waitUntilImportGCalEnd(websocketClient, () => + await waitUntilImportGCalEnd(websocketClient, () => syncDriver.importGCal({ userId }), ); @@ -514,6 +649,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 ImportGCalEndPayload); + + 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(); @@ -528,15 +692,16 @@ 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), ); - 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(); @@ -573,15 +738,16 @@ describe("SyncController", () => { data: { sync: { importGCal: "importing" } }, }); - const failReason = await syncDriver.waitUntilImportGCalEnd( + const failReason = await waitUntilImportGCalEnd( websocketClient, () => syncDriver.importGCal({ userId }), (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(); @@ -618,14 +784,13 @@ 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 ImportGCalEndPayload); expect(parsed).toHaveProperty("eventsCount"); expect(parsed).toHaveProperty("calendarsCount"); @@ -666,14 +831,13 @@ 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 ImportGCalEndPayload); expect(parsed).toHaveProperty("eventsCount"); expect(parsed).toHaveProperty("calendarsCount"); @@ -707,7 +871,7 @@ describe("SyncController", () => { ); await expect( - syncDriver.waitUntilImportGCalStart( + waitUntilImportGCalStart( websocketClient, () => syncDriver.importGCal({ userId }), () => Promise.resolve(true), @@ -734,13 +898,12 @@ 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 ImportGCalEndPayload); expect(parsed).toHaveProperty("eventsCount"); expect(parsed).toHaveProperty("calendarsCount"); @@ -765,7 +928,7 @@ describe("SyncController", () => { ); await expect( - baseDriver.waitUntilWebsocketEvent( + waitUntilUserWebsocketEvent( websocketClient, EVENT_CHANGED, () => syncDriver.importGCal({ userId }), diff --git a/packages/backend/src/sync/controllers/sync.controller.ts b/packages/backend/src/sync/controllers/sync.controller.ts index c4c45dfc8..81684e931 100644 --- a/packages/backend/src/sync/controllers/sync.controller.ts +++ b/packages/backend/src/sync/controllers/sync.controller.ts @@ -113,24 +113,33 @@ export class SyncController { resourceId, }); - const metadata = await userMetadataService.fetchUserMetadata(userId); + const metadata = await userMetadataService.fetchUserMetadata( + userId, + undefined, + { skipAssessment: true }, + ); + const importStatus = metadata.sync?.importGCal; - if (metadata.sync?.importGCal === "importing") { + if (importStatus === "importing" || importStatus === "restart") { logger.info( - `Skipped Google sync recovery because full import is already running for user: ${userId}`, + `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 + // existence, not validity, so we must force-restart directly. userService .restartGoogleCalendarSync(userId, { force: true }) - .catch((err) => + .catch((err) => { logger.error( - `Something went wrong recovering Google calendars for user: ${userId}`, + `Something went wrong with recovering google calendars for user: ${userId}`, err, - ), - ); + ); + }); res.status(Status.NO_CONTENT).send(); }; @@ -273,10 +282,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.test.ts b/packages/backend/src/sync/services/sync.service.test.ts index c9265ff84..5e91f6218 100644 --- a/packages/backend/src/sync/services/sync.service.test.ts +++ b/packages/backend/src/sync/services/sync.service.test.ts @@ -191,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(), @@ -201,4 +201,27 @@ describe("SyncService", () => { expect(cleanupSpy).toHaveBeenCalledTimes(1); }); }); + + describe("cleanupStaleWatchChannel", () => { + it("ignores stale notifications when no exact watch record exists", async () => { + const user = await UserDriver.createUser(); + const watch = await createWatch(user._id.toString()); + const stopWatchSpy = jest.spyOn(syncService, "stopWatch"); + + await expect( + syncService.cleanupStaleWatchChannel({ + resource: Resource_Sync.EVENTS, + channelId: new ObjectId(), + resourceId: watch.resourceId, + resourceState: XGoogleResourceState.EXISTS, + expiration: faker.date.future(), + }), + ).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 f478d516b..2f037efe8 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,76 +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; - } - }), - ); - - 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"; @@ -182,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"; @@ -197,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"; @@ -258,7 +228,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, @@ -266,19 +236,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 }; }), @@ -306,14 +270,20 @@ 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) { - 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; } @@ -334,7 +304,9 @@ class SyncService { data: { sync: { incrementalGCalSync: "completed" } }, }); - webSocketServer.handleImportGCalEnd(userId); + webSocketServer.handleImportGCalEnd(userId, { + status: "completed", + }); webSocketServer.handleBackgroundCalendarChange(userId); return result; @@ -349,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-metadata.service.test.ts b/packages/backend/src/user/services/user-metadata.service.test.ts index 9b03eaaee..d8d81fe11 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,27 @@ +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 userMetadataService from "@backend/user/services/user-metadata.service"; +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(); + beforeAll(initSupertokens); - beforeEach(setupTestDb); + beforeAll(setupTestDb); beforeEach(cleanupCollections); afterAll(cleanupTestDb); @@ -18,14 +30,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 +48,134 @@ 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("returns healthy without active watches when running without https", async () => { + const { user } = await UtilDriver.setupTestUser(); + const userId = user._id.toString(); + const isUsingHttpsSpy = isUsingHttps as jest.Mock; + isUsingHttpsSpy.mockReturnValue(false); + + await WatchDriver.deleteManyByUser(userId); + + const metadata = await driver.fetchUserMetadata(userId); + + expect(metadata.google).toMatchObject({ + hasRefreshToken: true, + connectionStatus: "connected", + syncStatus: "healthy", + }); + + isUsingHttpsSpy.mockRestore(); + }); + + 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 + .spyOn(userService, "restartGoogleCalendarSync") + .mockResolvedValue(); + + const metadata = await driver.fetchUserMetadata(userId); + + expect(metadata.google).toMatchObject({ + hasRefreshToken: true, + connectionStatus: "connected", + syncStatus: "attention", + }); + expect(restartSpy).not.toHaveBeenCalled(); + + 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("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 + .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..637662a62 100644 --- a/packages/backend/src/user/services/user-metadata.service.ts +++ b/packages/backend/src/user/services/user-metadata.service.ts @@ -2,10 +2,152 @@ import mergeWith from "lodash.mergewith"; import SupertokensUserMetadata, { type JSONObject, } from "supertokens-node/recipe/usermetadata"; -import { type UserMetadata } from "@core/types/user.types"; +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 { isUsingHttps } from "@backend/sync/util/sync.util"; import { findCompassUserBy } from "@backend/user/queries/user.queries"; +type GoogleMetadataAssessment = { + hasRefreshToken: boolean; + connectionStatus: GoogleConnectionStatus; + syncStatus: GoogleSyncStatus; +}; + +type GetUserMetadataResponse = { + status: string; + metadata: UserMetadata; +}; + class UserMetadataService { + private getStoredUserMetadata = async ( + userId: string, + userContext?: Record, + ): Promise => { + const result = (await SupertokensUserMetadata.getUserMetadata( + userId, + userContext, + )) as GetUserMetadataResponse; + + if (result.status !== "OK") + throw new Error("Failed to fetch user metadata"); + + return result.metadata; + }; + + 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 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; + } + + if (!isUsingHttps()) { + return true; + } + + 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), + ); + } + + 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", + }; + } + + const importStatus = storedMetadata.sync?.importGCal; + + if (importStatus === "importing" || importStatus === "restart") { + return { + hasRefreshToken, + connectionStatus, + syncStatus: "repairing", + }; + } + + const isHealthy = await this.isGoogleSyncHealthy(userId); + + if (isHealthy) { + return { + hasRefreshToken, + connectionStatus, + syncStatus: "healthy", + }; + } + + if (importStatus === "errored") { + return { + hasRefreshToken, + connectionStatus, + syncStatus: "attention", + }; + } + + return { + hasRefreshToken, + connectionStatus, + syncStatus: "attention", + }; + }; + /* * updateUserMetadata * @@ -21,39 +163,52 @@ class UserMetadataService { userId: string; data: Partial; }): Promise => { - const value = await this.fetchUserMetadata(userId); - const update = mergeWith(value, data); + const value = await this.getStoredUserMetadata(userId); + 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 ( userId: string, userContext?: Record, + options?: { skipAssessment?: boolean }, ): Promise => { - const { status, metadata } = await SupertokensUserMetadata.getUserMetadata( - userId, - userContext, - ); + const metadata = await this.getStoredUserMetadata(userId, userContext); - if (status !== "OK") throw new Error("Failed to fetch user metadata"); + if (options?.skipAssessment) { + const user = await findCompassUserBy("_id", userId); + const hasRefreshToken = Boolean(user?.google?.gRefreshToken); + const connectionStatus = this.getGoogleConnectionStatus(user); - // Enrich with Google token status - const user = await findCompassUserBy("_id", userId); - const hasRefreshToken = Boolean(user?.google?.gRefreshToken); + return { + ...metadata, + google: { + ...metadata.google, + hasRefreshToken, + connectionStatus, + syncStatus: metadata.google?.syncStatus ?? "none", + }, + }; + } - const typedMetadata = metadata as UserMetadata; + 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.test.ts b/packages/backend/src/user/services/user.service.test.ts index d6ef59a35..fd20b6220 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 }), + ), ), ); @@ -282,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"); @@ -297,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(); @@ -311,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 b00562c05..53e51f5b5 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(); @@ -207,6 +210,12 @@ class UserService { { _id }, { $set: { "google.gRefreshToken": "" } }, ); + await userMetadataService.updateUserMetadata({ + userId, + data: { + sync: { importGCal: "restart", incrementalGCalSync: "restart" }, + }, + }); }; startGoogleCalendarSync = async ( @@ -266,16 +275,16 @@ 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); 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; } @@ -293,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 { @@ -315,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/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/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/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))); }); }); } 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/google/google.auth.util.test.ts b/packages/web/src/auth/google/google.auth.util.test.ts index 31a63e143..a965ff366 100644 --- a/packages/web/src/auth/google/google.auth.util.test.ts +++ b/packages/web/src/auth/google/google.auth.util.test.ts @@ -9,6 +9,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 +36,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 +168,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 +187,14 @@ describe("google-auth.util", () => { ); }); + it("reconnects socket so the client gets a fresh session after revocation", () => { + const { reconnect } = require("@web/socket/client/socket.client"); + + handleGoogleRevoked(); + + expect(reconnect).toHaveBeenCalled(); + }); + it("marks Google as revoked in session state", () => { expect(isGoogleRevoked()).toBe(false); @@ -190,7 +209,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..e12834152 100644 --- a/packages/web/src/auth/google/google.auth.util.ts +++ b/packages/web/src/auth/google/google.auth.util.ts @@ -6,12 +6,14 @@ 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 } from "@web/socket/client/socket.client"; import { store } from "@web/store"; export interface AuthenticateResult { @@ -53,6 +55,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 +67,10 @@ export const handleGoogleRevoked = () => { store.dispatch( triggerFetch({ reason: Sync_AsyncStateContextReason.GOOGLE_REVOKED }), ); + + // 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(); }; /** diff --git a/packages/web/src/auth/hooks/oauth/useConnectGoogle.test.ts b/packages/web/src/auth/hooks/oauth/useConnectGoogle.test.ts index 7c5ff4888..9f6aad3d1 100644 --- a/packages/web/src/auth/hooks/oauth/useConnectGoogle.test.ts +++ b/packages/web/src/auth/hooks/oauth/useConnectGoogle.test.ts @@ -1,23 +1,36 @@ 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 { hasUserEverAuthenticated } from "@web/auth/state/auth.state.util"; +import { SyncApi } from "@web/common/apis/sync.api"; +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 } 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/auth/state/auth.state.util"); +jest.mock("@web/common/apis/sync.api"); 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 +>; +const mockHasUserEverAuthenticated = + hasUserEverAuthenticated as jest.MockedFunction< + typeof hasUserEverAuthenticated + >; +const mockSyncApi = SyncApi as jest.Mocked; describe("useConnectGoogle", () => { const mockDispatch = jest.fn(); @@ -28,57 +41,333 @@ describe("useConnectGoogle", () => { mockUseAppDispatch.mockReturnValue(mockDispatch); mockUseGoogleAuth.mockReturnValue({ login: mockLogin, + data: null, + loading: false, }); - mockUseSession.mockReturnValue({ - authenticated: false, - setAuthenticated: jest.fn(), + 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, + isImportPending: false, + }; + } + + return undefined; }); - // Default: Google not revoked - jest.spyOn(googleAuthState, "isGoogleRevoked").mockReturnValue(false); }); - it("returns true when Google Calendar is connected", () => { - mockUseSession.mockReturnValue({ - authenticated: true, - setAuthenticated: jest.fn(), + it("returns checking state when metadata is still loading", () => { + const { result } = renderHook(() => useConnectGoogle()); + + expect(mockUseGoogleAuth).toHaveBeenCalledWith(); + 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.isGoogleCalendarConnected).toBe(true); + 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"); + expect(result.current.sidebarStatus.tooltip).toBe( + "Google Calendar not connected. Click to connect.", + ); }); - it("returns false when Google Calendar is not connected", () => { - mockUseSession.mockReturnValue({ - authenticated: false, - setAuthenticated: jest.fn(), + it("returns connected state when metadata is healthy", () => { + mockUseAppSelector.mockImplementation((selector) => { + if (selector === selectGoogleMetadata) { + return { + connectionStatus: "connected", + syncStatus: "healthy", + }; + } + + if (selector === selectUserMetadataStatus) { + return "loaded"; + } + + if (selector === selectImportGCalState) { + return { + importing: false, + isImportPending: false, + }; + } + + return undefined; }); const { result } = renderHook(() => useConnectGoogle()); - expect(result.current.isGoogleCalendarConnected).toBe(false); + expect(mockUseGoogleAuth).toHaveBeenCalledWith(); + 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"); + expect(result.current.sidebarStatus.isDisabled).toBe(true); }); - it("returns false when authenticated but Google is revoked", () => { - mockUseSession.mockReturnValue({ - authenticated: true, - setAuthenticated: jest.fn(), + it("returns reconnect state when refresh token is missing", () => { + mockUseAppSelector.mockImplementation((selector) => { + if (selector === selectGoogleMetadata) { + return { + connectionStatus: "reconnect_required", + syncStatus: "none", + }; + } + + if (selector === selectUserMetadataStatus) { + return "loaded"; + } + + if (selector === selectImportGCalState) { + return { + importing: false, + isImportPending: false, + }; + } + + return undefined; }); - jest.spyOn(googleAuthState, "isGoogleRevoked").mockReturnValue(true); const { result } = renderHook(() => useConnectGoogle()); - expect(result.current.isGoogleCalendarConnected).toBe(false); + expect(mockUseGoogleAuth).toHaveBeenCalledWith(); + expect(result.current.commandAction.label).toBe( + "Reconnect Google Calendar", + ); + expect(result.current.sidebarStatus.icon).toBe("LinkBreakIcon"); + result.current.commandAction.onSelect?.(); + + expect(mockLogin).toHaveBeenCalled(); + expect(mockDispatch).toHaveBeenCalledWith( + settingsSlice.actions.closeCmdPalette(), + ); }); - it("logs in and closes the command palette on connect", () => { + it("returns syncing state while repair is running", () => { + mockUseAppSelector.mockImplementation((selector) => { + if (selector === selectGoogleMetadata) { + return { + connectionStatus: "connected", + syncStatus: "repairing", + }; + } + + if (selector === selectUserMetadataStatus) { + return "loaded"; + } + + if (selector === selectImportGCalState) { + return { + importing: false, + isImportPending: false, + }; + } + + return undefined; + }); + const { result } = renderHook(() => useConnectGoogle()); - result.current.onConnectGoogleCalendar(); + 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(); + expect(result.current.sidebarStatus.icon).toBe("SpinnerIcon"); + expect(result.current.sidebarStatus.isDisabled).toBe(true); + }); - expect(mockLogin).toHaveBeenCalled(); + it("returns repair state when sync needs attention", () => { + mockUseAppSelector.mockImplementation((selector) => { + if (selector === selectGoogleMetadata) { + return { + connectionStatus: "connected", + syncStatus: "attention", + }; + } + + if (selector === selectUserMetadataStatus) { + return "loaded"; + } + + if (selector === selectImportGCalState) { + return { + importing: false, + isImportPending: false, + }; + } + + return undefined; + }); + + const { result } = renderHook(() => useConnectGoogle()); + + 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"); + expect(result.current.sidebarStatus.tooltip).toBe( + "Google Calendar needs repair. Click to repair.", + ); + + result.current.sidebarStatus.onSelect?.(); + + 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).not.toHaveBeenCalledWith( settingsSlice.actions.closeCmdPalette(), ); + + jest.clearAllMocks(); + + result.current.commandAction.onSelect?.(); + + expect(mockSyncApi.importGCal).toHaveBeenCalledWith({ force: 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 === selectUserMetadataStatus) { + return "loaded"; + } + + if (selector === selectImportGCalState) { + return { + importing: true, + isImportPending: true, + }; + } + + return undefined; + }); + + const { result } = renderHook(() => useConnectGoogle()); + + expect(mockUseGoogleAuth).toHaveBeenCalledWith(); + expect(result.current.commandAction.isDisabled).toBe(true); + 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 === selectUserMetadataStatus) { + return "loaded"; + } + + if (selector === selectImportGCalState) { + return { + importing: true, + isImportPending: true, + }; + } + + return undefined; + }); + + const { result } = renderHook(() => useConnectGoogle()); + + expect(mockUseGoogleAuth).toHaveBeenCalledWith(); + expect(result.current.commandAction.label).toBe( + "Reconnect Google Calendar", + ); + 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(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 336890904..2589a392b 100644 --- a/packages/web/src/auth/hooks/oauth/useConnectGoogle.ts +++ b/packages/web/src/auth/hooks/oauth/useConnectGoogle.ts @@ -1,25 +1,259 @@ import { useCallback } from "react"; -import { isGoogleRevoked } from "@web/auth/google/google.auth.state"; +import { + type GoogleConnectionStatus, + type GoogleSyncStatus, + type UserMetadata, +} from "@core/types/user.types"; import { useGoogleAuth } from "@web/auth/hooks/oauth/useGoogleAuth"; -import { useSession } from "@web/auth/hooks/session/useSession"; +import { hasUserEverAuthenticated } from "@web/auth/state/auth.state.util"; +import { SyncApi } from "@web/common/apis/sync.api"; +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 { useAppDispatch } from "@web/store/store.hooks"; +import type { RootState } from "@web/store"; +import { useAppDispatch, useAppSelector } from "@web/store/store.hooks"; + +type GoogleUiState = + | "checking" + | "not_connected" + | "reconnect_required" + | "connected_healthy" + | "connected_repairing" + | "connected_attention"; + +type SidebarStatusIcon = + | "CloudArrowUpIcon" + | "LinkBreakIcon" + | "CheckCircleIcon" + | "SpinnerIcon" + | "CloudWarningIcon"; + +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_ICON: CommandActionIcon = "CloudArrowUpIcon"; + +const getGoogleUiState = ({ + connectionStatus, + syncStatus, + isImporting, + isCheckingStatus, +}: { + connectionStatus: GoogleConnectionStatus; + syncStatus: GoogleSyncStatus; + isImporting: boolean; + isCheckingStatus: boolean; +}): GoogleUiState => { + if (connectionStatus === "reconnect_required") { + return "reconnect_required"; + } + + if (isImporting) { + return "connected_repairing"; + } + + if (isCheckingStatus) { + return "checking"; + } + + if (connectionStatus === "connected" && syncStatus === "repairing") { + return "connected_repairing"; + } + + if (connectionStatus === "connected" && syncStatus === "attention") { + return "connected_attention"; + } + + if (connectionStatus === "connected") { + return "connected_healthy"; + } + + return "not_connected"; +}; + +const getGoogleUiConfig = ( + state: GoogleUiState, + onConnectGoogle: () => void, + onRepairGoogle: () => void, + 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: { + label: "Connect Google Calendar", + icon: COMMAND_ICON, + isDisabled: false, + onSelect: onConnectGoogle, + }, + sidebarStatus: { + icon: "CloudArrowUpIcon", + tooltip: "Google Calendar not connected. Click to connect.", + isDisabled: false, + onSelect: onConnectGoogle, + }, + }; + case "reconnect_required": + return { + commandAction: { + label: "Reconnect Google Calendar", + icon: COMMAND_ICON, + isDisabled: false, + onSelect: onConnectGoogle, + }, + sidebarStatus: { + icon: "LinkBreakIcon", + tooltip: "Google Calendar needs reconnecting. Click to reconnect.", + isDisabled: false, + onSelect: onConnectGoogle, + }, + }; + case "connected_repairing": + return { + commandAction: { + label: "Syncing Google Calendar…", + icon: COMMAND_ICON, + isDisabled: true, + }, + sidebarStatus: { + icon: "SpinnerIcon", + tooltip: "Google Calendar is syncing in the background.", + isDisabled: true, + }, + }; + case "connected_attention": + return { + commandAction: { + label: "Repair Google Calendar", + icon: COMMAND_ICON, + isDisabled: false, + onSelect: onRepairGoogle, + }, + sidebarStatus: { + icon: "CloudWarningIcon", + tooltip: "Google Calendar needs repair. Click to repair.", + isDisabled: false, + onSelect: onRepairGoogleFromSidebar, + }, + }; + case "connected_healthy": + return { + commandAction: { + label: "Google Calendar Connected", + icon: COMMAND_ICON, + isDisabled: true, + }, + sidebarStatus: { + icon: "CheckCircleIcon", + tooltip: "Google Calendar connected.", + isDisabled: true, + }, + }; + } +}; export const useConnectGoogle = () => { const dispatch = useAppDispatch(); - const { authenticated } = useSession(); + 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 connectionStatus = googleMetadata?.connectionStatus ?? "not_connected"; + const syncStatus = googleMetadata?.syncStatus ?? "none"; const { login } = useGoogleAuth(); - const onConnectGoogleCalendar = useCallback(() => { - login(); + const onOpenGoogleAuth = useCallback(() => { + void login(); dispatch(settingsSlice.actions.closeCmdPalette()); }, [dispatch, login]); - // Google is only truly connected if authenticated AND not revoked - const isGoogleCalendarConnected = authenticated && !isGoogleRevoked(); + const onRepairGoogleCalendarBase = useCallback(() => { + const run = async () => { + dispatch(importGCalSlice.actions.clearImportResults(undefined)); + dispatch(importGCalSlice.actions.setIsImportPending(true)); + dispatch(importGCalSlice.actions.importing(true)); - return { - isGoogleCalendarConnected, - onConnectGoogleCalendar, - }; + 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 onRepairGoogleCalendar = useCallback(() => { + dispatch(settingsSlice.actions.closeCmdPalette()); + onRepairGoogleCalendarBase(); + }, [dispatch, onRepairGoogleCalendarBase]); + + const onRepairGoogleCalendarFromSidebar = useCallback(() => { + onRepairGoogleCalendarBase(); + }, [onRepairGoogleCalendarBase]); + + const isCheckingStatus = + !googleMetadata && + userMetadataStatus !== "loaded" && + hasUserEverAuthenticated(); + const state = getGoogleUiState({ + connectionStatus, + syncStatus, + isImporting: importGCal.importing || importGCal.isImportPending, + isCheckingStatus, + }); + + return getGoogleUiConfig( + state, + onOpenGoogleAuth, + onRepairGoogleCalendar, + onRepairGoogleCalendarFromSidebar, + ); }; diff --git a/packages/web/src/auth/hooks/oauth/useGoogleAuth.test.ts b/packages/web/src/auth/hooks/oauth/useGoogleAuth.test.ts index 8a6e46132..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, @@ -5,13 +6,16 @@ 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"; +import { useAppDispatch } from "@web/store/store.hooks"; // 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,12 +42,17 @@ 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 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(); @@ -64,6 +73,7 @@ describe("useGoogleAuth", () => { }); mockAuthenticate.mockResolvedValue({ success: true }); mockSyncLocalEvents.mockResolvedValue({ syncedCount: 0, success: true }); + mockRefreshUserMetadata.mockResolvedValue(); }); afterEach(() => { @@ -141,6 +151,7 @@ describe("useGoogleAuth", () => { expect(mockMarkUserAsAuthenticated).toHaveBeenCalled(); expect(mockSetAuthenticated).toHaveBeenCalledWith(true); + expect(mockRefreshUserMetadata).toHaveBeenCalledTimes(1); }); describe("onStart callback", () => { @@ -154,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" }), @@ -165,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 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 82cbf694a..21e9cf67f 100644 --- a/packages/web/src/auth/session/SessionProvider.tsx +++ b/packages/web/src/auth/session/SessionProvider.tsx @@ -18,8 +18,11 @@ 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"; +import { refreshUserMetadata } from "./user-metadata.util"; SuperTokens.init({ appInfo: { @@ -63,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); @@ -93,9 +99,11 @@ 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": + store.dispatch(userMetadataSlice.actions.clear()); socket.disconnect(); break; } 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/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/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/common/utils/toast/session-expired.toast.test.tsx b/packages/web/src/common/utils/toast/session-expired.toast.test.tsx index 85f7fd0ab..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,6 +26,7 @@ describe("SessionExpiredToast", () => { it("renders session-expired message and reconnect button", () => { render(); + 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 e66826b1e..1df79103b 100644 --- a/packages/web/src/common/utils/toast/session-expired.toast.tsx +++ b/packages/web/src/common/utils/toast/session-expired.toast.tsx @@ -9,7 +9,7 @@ export const SessionExpiredToast = ({ toastId }: SessionExpiredToastProps) => { const { login } = useGoogleAuth(); const handleReconnect = () => { - login(); + void login(); toast.dismiss(toastId); }; 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} + + 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 new file mode 100644 index 000000000..6e2a9be76 --- /dev/null +++ b/packages/web/src/ducks/auth/slices/user-metadata.slice.ts @@ -0,0 +1,54 @@ +import { + type PayloadAction, + type Slice, + type SliceCaseReducers, + createSlice, +} from "@reduxjs/toolkit"; +import { type UserMetadata } from "@core/types/user.types"; + +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: 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" +>; diff --git a/packages/web/src/socket/hooks/useGcalSync.test.ts b/packages/web/src/socket/hooks/useGcalSync.test.ts index 1e410e315..013ea6486 100644 --- a/packages/web/src/socket/hooks/useGcalSync.test.ts +++ b/packages/web/src/socket/hooks/useGcalSync.test.ts @@ -1,11 +1,14 @@ import { useDispatch } from "react-redux"; import { renderHook } from "@testing-library/react"; import { + FETCH_USER_METADATA, GOOGLE_REVOKED, IMPORT_GCAL_END, 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, selectIsImportPending, @@ -27,6 +30,7 @@ jest.mock("@web/store/store.hooks", () => ({ })); jest.mock("../client/socket.client", () => ({ socket: { + emit: jest.fn(), on: jest.fn(), removeListener: jest.fn(), }, @@ -48,9 +52,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 +148,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 +171,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 +199,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 +211,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 +227,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", () => { @@ -241,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), @@ -263,6 +299,7 @@ describe("useGcalSync", () => { calendarsCount: 2, }), ); + expect(socket.emit).toHaveBeenCalledWith(FETCH_USER_METADATA); expect(triggerFetch).toHaveBeenCalledWith({ reason: "IMPORT_COMPLETE", }); @@ -271,20 +308,23 @@ 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), ); + expect(socket.emit).toHaveBeenCalledWith(FETCH_USER_METADATA); expect(importGCalSlice.actions.setImportResults).not.toHaveBeenCalled(); }); @@ -292,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; @@ -308,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( @@ -319,6 +363,61 @@ describe("useGcalSync", () => { calendarsCount: 2, }), ); + 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(); + }); + + 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.", + ), + ); }); }); @@ -333,9 +432,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()); @@ -360,10 +461,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) @@ -377,6 +479,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", @@ -385,9 +488,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()); @@ -401,21 +506,26 @@ 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( importGCalSlice.actions.importing(false), ); + expect(socket.emit).toHaveBeenCalledWith(FETCH_USER_METADATA); }); 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()); @@ -423,9 +533,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) @@ -437,6 +549,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, @@ -445,9 +558,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()); @@ -456,7 +571,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( @@ -465,13 +580,16 @@ describe("useGcalSync", () => { expect(mockDispatch).toHaveBeenCalledWith( importGCalSlice.actions.setImportResults({}), ); + expect(socket.emit).toHaveBeenCalledWith(FETCH_USER_METADATA); }); 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()); @@ -479,8 +597,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), @@ -491,16 +612,16 @@ describe("useGcalSync", () => { calendarsCount: 4, }), ); + 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()); @@ -508,8 +629,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( @@ -518,13 +641,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", ), ); - // Should NOT set results + expect(socket.emit).toHaveBeenCalledWith(FETCH_USER_METADATA); 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 e9f096284..02ca49684 100644 --- a/packages/web/src/socket/hooks/useGcalSync.ts +++ b/packages/web/src/socket/hooks/useGcalSync.ts @@ -1,14 +1,16 @@ import { useCallback, useEffect, useRef } from "react"; import { useDispatch } from "react-redux"; import { + FETCH_USER_METADATA, GOOGLE_REVOKED, IMPORT_GCAL_END, IMPORT_GCAL_START, USER_METADATA, } from "@core/constants/websocket.constants"; import { type UserMetadata } from "@core/types/user.types"; -import { shouldImportGCal } from "@core/util/event/event.util"; +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"; import { selectIsImportPending } from "@web/ducks/events/selectors/sync.selector"; import { @@ -37,38 +39,32 @@ export const useGcalSync = () => { ); const onImportEnd = useCallback( - (payload?: { eventsCount?: number; calendarsCount?: number } | string) => { + (payload?: ImportGCalEndPayload) => { dispatch(importGCalSlice.actions.importing(false)); + socket.emit(FETCH_USER_METADATA); if (!isImportPendingRef.current) { 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; + } + + if (payload?.status === "ignored") { + return; } - // Set import results to trigger completion results display - dispatch(importGCalSlice.actions.setImportResults(importResults)); + 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, @@ -84,12 +80,24 @@ 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"; + 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)); @@ -109,7 +117,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/socket/provider/SocketProvider.interaction.test.tsx b/packages/web/src/socket/provider/SocketProvider.interaction.test.tsx index ba27c8085..47bb597d3 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,84 @@ 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); - consoleSpy.mockRestore(); + await act(async () => { + metadataCallback?.({ sync: { importGCal: "completed" } }); + }); + + await act(async () => { + jest.advanceTimersByTime(100); + }); + + expect( + screen.queryByText("Importing your Google Calendar events..."), + ).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.", + ); }); }); @@ -507,9 +588,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(); 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/Calendar/components/Sidebar/SidebarIconRow/SidebarIconRow.test.tsx b/packages/web/src/views/Calendar/components/Sidebar/SidebarIconRow/SidebarIconRow.test.tsx new file mode 100644 index 000000000..de5b23615 --- /dev/null +++ b/packages/web/src/views/Calendar/components/Sidebar/SidebarIconRow/SidebarIconRow.test.tsx @@ -0,0 +1,173 @@ +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(); + +jest.mock("@web/auth/hooks/oauth/useGoogleAuth", () => ({ + useGoogleAuth: () => ({ + login: mockLogin, + }), +})); + +jest.mock("@web/common/apis/sync.api", () => ({ + SyncApi: { + importGCal: jest.fn().mockResolvedValue(undefined), + }, +})); + +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 repair.", + }), + ); + + expect(SyncApi.importGCal).toHaveBeenCalledWith({ force: true }); + }); +}); 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..7b4dc9edd --- /dev/null +++ b/packages/web/src/views/CmdPalette/CmdPalette.test.tsx @@ -0,0 +1,146 @@ +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", () => { + 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, + }), +})); + +jest.mock("@web/common/apis/sync.api", () => ({ + SyncApi: { + importGCal: jest.fn().mockResolvedValue(undefined), + }, +})); + +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: "Google Calendar Connected" }), + ).toBeDisabled(); + }); + + it("starts a forced repair 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: "Repair Google Calendar" }), + ), + ); + + 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 64dbb9490..1e04c2a4a 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 { commandAction } = useConnectGoogle(); const authCmdItems = useAuthCmdItems(); const handleCreateSomedayDraft = async ( @@ -82,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", @@ -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: 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 20dbf77e8..0c5ee174a 100644 --- a/packages/web/src/views/Day/components/DayCmdPalette.test.tsx +++ b/packages/web/src/views/Day/components/DayCmdPalette.test.tsx @@ -3,7 +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 * as useSessionModule from "@web/auth/hooks/session/useSession"; +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"; @@ -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, @@ -47,6 +118,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(); @@ -246,12 +323,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 } }, @@ -259,43 +331,51 @@ describe("DayCmdPalette", () => { ); expect(screen.getByText("Connect Google Calendar")).toBeInTheDocument(); - expect( - screen.queryByText("Google Calendar Connected"), - ).not.toBeInTheDocument(); }); - it("shows 'Google Calendar Connected' when authenticated", async () => { - jest.spyOn(useSessionModule, "useSession").mockReturnValue({ - authenticated: true, - setAuthenticated: jest.fn(), - }); - + it("disables the generic action when Google Calendar is healthy", async () => { await act(() => render(, { - state: { settings: { isCmdPaletteOpen: true } }, + state: { + settings: { isCmdPaletteOpen: true }, + userMetadata: { + current: { + google: { + connectionStatus: "connected", + syncStatus: "healthy", + }, + }, + }, + }, }), ); - expect(screen.getByText("Google Calendar Connected")).toBeInTheDocument(); expect( - screen.queryByText("Connect Google Calendar"), - ).not.toBeInTheDocument(); + screen.getByRole("button", { name: "Google Calendar Connected" }), + ).toBeDisabled(); }); - 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 +383,49 @@ 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("disables the generic action while repair is running", async () => { await act(() => render(, { - state: { settings: { isCmdPaletteOpen: true } }, + state: { + settings: { isCmdPaletteOpen: true }, + userMetadata: { + current: { + google: { + connectionStatus: "connected", + syncStatus: "repairing", + }, + }, + }, + }, }), ); + expect( + screen.getByRole("button", { name: "Syncing Google Calendar…" }), + ).toBeDisabled(); + }); + + it("keeps the generic action enabled 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(SyncApi.importGCal).toHaveBeenCalledWith({ force: true }); }); }); }); diff --git a/packages/web/src/views/Day/components/DayCmdPalette.tsx b/packages/web/src/views/Day/components/DayCmdPalette.tsx index ea31d754e..64239762a 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 { commandAction } = 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: 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 ecb39e3e2..e01d7cdb0 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"; @@ -37,8 +38,10 @@ jest.mock("react-cmdk", () => { {children} ); - CommandPalette.ListItem = ({ children, onClick }: any) => ( - + CommandPalette.ListItem = ({ children, disabled, onClick }: any) => ( + ); CommandPalette.FreeSearchAction = () =>
No results
; @@ -61,16 +64,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", () => ({ @@ -79,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: { @@ -147,41 +146,102 @@ 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(); expect( - screen.queryByText("Google Calendar Connected"), - ).not.toBeInTheDocument(); + screen.getByRole("button", { name: "Connect Google Calendar" }), + ).toBeEnabled(); }); - it("shows 'Google Calendar Connected' when authenticated", () => { - mockAuthenticated = true; - render(, { state: initialState }); + it("disables the generic action when Google Calendar is healthy", () => { + render(, { + state: { + ...initialState, + userMetadata: { + current: { + google: { + connectionStatus: "connected", + syncStatus: "healthy", + }, + }, + }, + }, + }); - expect(screen.getByText("Google Calendar Connected")).toBeInTheDocument(); expect( - screen.queryByText("Connect Google Calendar"), - ).not.toBeInTheDocument(); + screen.getByRole("button", { name: "Google Calendar Connected" }), + ).toBeDisabled(); }); - 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.getByRole("button", { name: "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("disables the generic action while repairing", async () => { + render(, { + state: { + ...initialState, + userMetadata: { + current: { + google: { + connectionStatus: "connected", + syncStatus: "repairing", + }, + }, + }, + }, + }); + + const button = screen.getByRole("button", { + name: "Syncing Google Calendar…", + }); + expect(button).toBeDisabled(); + }); + + it("starts a forced repair when sync needs attention", async () => { + render(, { + state: { + ...initialState, + userMetadata: { + current: { + google: { + connectionStatus: "connected", + syncStatus: "attention", + }, + }, + }, + }, + }); - act(() => fireEvent.click(screen.getByText("Google Calendar Connected"))); + act(() => + fireEvent.click( + screen.getByRole("button", { name: "Repair Google Calendar" }), + ), + ); - expect(mockLogin).not.toHaveBeenCalled(); + await waitFor(() => + expect(SyncApi.importGCal).toHaveBeenCalledWith({ force: true }), + ); }); }); }); diff --git a/packages/web/src/views/Now/components/NowCmdPalette.tsx b/packages/web/src/views/Now/components/NowCmdPalette.tsx index 06b4e6238..db313331e 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 { commandAction } = 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: commandAction.label, + icon: commandAction.icon, + disabled: commandAction.isDisabled, + onClick: commandAction.onSelect, }, ...authCmdItems, {