From f17731ee2b0e9c41658cd7d09ee0502964d74c74 Mon Sep 17 00:00:00 2001 From: Cursor Agent Date: Thu, 13 Aug 2026 00:08:24 +0000 Subject: [PATCH 1/7] fix(sync,web,backend): keep local calendar and converge import state MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Email/password signup then Add account hid the Compass calendar, left "Adding your calendar…" stuck, and could silently fork a new user from the Google sign-in path. Push derived connection state from bootstrap completion, heal missing-resource and reconnect clobber, force-refresh metadata on SSE/focus/poll, attribute local calendars per account, and refuse SIGNUP under an existing session. Co-authored-by: Tyler Dane --- docs/README.md | 1 + docs/architecture/multi-account-sync.md | 61 +++++++ .../google/google.auth.service.test.ts | 27 ++++ .../services/google/google.auth.service.ts | 11 +- .../src/common/errors/auth/auth.errors.ts | 8 + .../supertokens.middleware.handlers.ts | 4 +- .../audit.db.test.ts | 2 - packages/sync/src/app.ts | 1 + .../busy-availability.service.db.test.ts | 16 +- .../connection-refresh.service.db.test.ts | 54 ++++++- .../domain/connection-refresh.service.test.ts | 23 ++- .../src/domain/connection-refresh.service.ts | 39 ++++- .../connection-retention.service.db.test.ts | 4 - ...onnection-state-refresh.service.db.test.ts | 88 ++++++++++- .../connection-state-refresh.service.ts | 11 +- .../sync-job-dispatch.service.db.test.ts | 149 ++++++++++++++++++ .../src/domain/sync-job-dispatch.service.ts | 30 ++++ .../domain/sync-job-worker.service.db.test.ts | 4 +- .../src/safety/isolation-matrix.db.test.ts | 2 - .../src/server/availability.routes.db.test.ts | 2 - .../src/server/connection.routes.db.test.ts | 16 +- packages/sync/src/server/connection.routes.ts | 8 +- .../src/server/diagnostic.routes.db.test.ts | 2 - .../src/server/principal.routes.db.test.ts | 2 - .../provider-connection.contracts.ts | 2 - .../provider-connection.repository.db.test.ts | 32 +++- .../provider-connection.repository.ts | 7 +- .../health-snapshot.service.db.test.ts | 2 - .../user/util/user-metadata.util.test.ts | 30 +++- .../compass/user/util/user-metadata.util.ts | 10 +- .../useConnectGoogle.scope.test.tsx | 19 +++ .../useConnectGoogle/useConnectGoogle.ts | 15 +- .../web/src/calendars/calendar.util.test.ts | 6 +- packages/web/src/calendars/calendar.util.ts | 9 +- .../components/AuthModal/AuthModal.test.tsx | 25 +++ .../AuthModal/hooks/useAuthModal.ts | 23 ++- .../src/components/Settings/SettingsModal.tsx | 24 +-- .../CalendarList/CalendarList.test.tsx | 67 +++++--- .../Sidebar/CalendarList/CalendarList.tsx | 72 +++++---- .../web/src/sse/hooks/useGcalSSE.factory.ts | 12 +- .../src/sse/hooks/useSSEConnection.test.tsx | 6 + .../web/src/sse/hooks/useSSEConnection.ts | 14 +- .../src/sse/hooks/useSyncFocusRefresh.test.ts | 56 ++++++- .../web/src/sse/hooks/useSyncFocusRefresh.ts | 24 ++- .../sse/hooks/useTransientSyncPolling.test.ts | 127 +++++++++++++++ .../src/sse/hooks/useTransientSyncPolling.ts | 34 ++++ .../provider/SSEProvider.interaction.test.tsx | 6 +- packages/web/src/sse/provider/SSEProvider.tsx | 2 + .../Calendar/dayCalendarColumns.util.test.ts | 33 ++-- .../Calendar/dayCalendarColumns.util.ts | 31 +--- .../Calendar/useDayCalendarColumns.ts | 6 +- 51 files changed, 1056 insertions(+), 203 deletions(-) create mode 100644 docs/architecture/multi-account-sync.md create mode 100644 packages/web/src/sse/hooks/useTransientSyncPolling.test.ts create mode 100644 packages/web/src/sse/hooks/useTransientSyncPolling.ts diff --git a/docs/README.md b/docs/README.md index b05c354d02..0a67b8cc03 100644 --- a/docs/README.md +++ b/docs/README.md @@ -24,6 +24,7 @@ Internal documentation for engineers and agents working in the Compass repo. - [Repo Architecture](./architecture/repo-architecture.md) - [Event Domain Model](./architecture/event-domain-model.md) +- [Multi-account Sync](./architecture/multi-account-sync.md) - [Glossary](./architecture/glossary.md) ## Development And Operations diff --git a/docs/architecture/multi-account-sync.md b/docs/architecture/multi-account-sync.md new file mode 100644 index 0000000000..99769cc6d0 --- /dev/null +++ b/docs/architecture/multi-account-sync.md @@ -0,0 +1,61 @@ +# Multi-account sync invariants + +These rules keep Compass's per-account Google sync state honest after +email/password signup plus "Add account", reconnect, and missed SSE. + +Zustand remains the store for user metadata. The failure mode was refresh +discipline, not the store; migrating metadata to react-query would touch +dozens of consumers (auth, sidebar, settings, SSE handlers, and the +`__COMPASS_E2E_STORE__` Playwright bridge) to fix a problem four files own. +Calendars live in react-query and connections in zustand; refetching both +unconditionally on the same signals keeps them from diverging. + +## 1. Push on every transition + +`refreshConnectionState` is the only writer of derived connection state. On +change it persists the new state and appends a `kind: "connection"` +invalidation. The backend SSE bridge fans those invalidations out as +`calendarsChanged`. + +Any new job kind or route that changes state-derivation evidence (bootstrap +flags, cursors, credentials, calendar-list discovery, durable read failures) +must end by calling `refreshConnectionState` with the invalidations +repository. Do not grow a second push channel. Delete the dormant +`importProgress` invalidation kind and caller-less +`sseServer.publishUserMetadata` in a follow-up cleanup rather than adding +another path. + +At dispatch time the completing job is still claimed, so derivation often +lands on `catchingUp` first. That is still a change from `importing`, so the +invalidation fires, the client refetches, and the read-path refresh after the +job settles lands `healthy`. + +## 2. Pull reconciliation, unconditionally cheap + +Force-refresh metadata on every SSE signal, on stream open/reopen, and on +tab focus — regardless of connection state — plus a 20s poll while any +**single** connection is `connecting` / `importing` / `catchingUp`. + +The sync `GET /connections` read path re-derives state, so every metadata +pull is also a server-side reconciliation. The UI self-heals even with SSE +fully dead. Concurrent `force` refreshes chain onto one trailing fetch; +an epoch counter drops stale writes. + +Do not gate metadata reconciliation on HEALTHY/ATTENTION. That gate is only +for the provider Refresh enqueue. + +## 3. Per-account attribution + +User-visible status hangs off one connection, never the precedence-collapsed +aggregate. A stuck account must not pin an unattributed banner (or disable +reconciliation) for all accounts. + +- The local Compass calendar is its own sidebar section (the signed-in + user's email) once any Google account exists. It stays visible and + toggleable. LCV1/LCV2 still exclude it as a create target. +- A connected-but-still-importing account keeps its section header with + zero calendars so "Adding your calendar…" attributes to that account. +- Day view columns are active+visible calendars, matching Week. + +Follow-up candidate: `SidebarStatusBar` names the account when more than one +connection exists. diff --git a/packages/backend/src/auth/services/google/google.auth.service.test.ts b/packages/backend/src/auth/services/google/google.auth.service.test.ts index d6260cbdcc..7d94d11119 100644 --- a/packages/backend/src/auth/services/google/google.auth.service.test.ts +++ b/packages/backend/src/auth/services/google/google.auth.service.test.ts @@ -193,6 +193,33 @@ describe("handleGoogleAuth", () => { expect(googleAuthService.googleSignup).not.toHaveBeenCalled(); expect(adoptCalls).toHaveLength(0); }); + + it("does not create a user when an existing session would otherwise SIGNUP", async () => { + const success: GoogleSignInSuccess = { + providerUser: makeProviderUser(), + oAuthTokens: makeOAuthTokens(), + createdNewRecipeUser: true, + recipeUserId: faker.database.mongodbObjectId(), + loginMethodsLength: 1, + }; + + mockDetermineGoogleAuthMode.mockResolvedValue( + makeDecision({ authMode: "SIGNUP" }), + ); + + await expect( + googleAuthService.handleGoogleAuth(success, { + hasExistingSession: true, + }), + ).rejects.toMatchObject({ + result: + "You're already signed in — use Settings → Add account to connect this Google account.", + code: "GOOGLE_SIGNIN_WHILE_AUTHENTICATED", + }); + + expect(googleAuthService.googleSignup).not.toHaveBeenCalled(); + expect(adoptCalls).toHaveLength(0); + }); }); describe("SIGNIN path", () => { diff --git a/packages/backend/src/auth/services/google/google.auth.service.ts b/packages/backend/src/auth/services/google/google.auth.service.ts index 0c35b3006f..5bfe6bd632 100644 --- a/packages/backend/src/auth/services/google/google.auth.service.ts +++ b/packages/backend/src/auth/services/google/google.auth.service.ts @@ -202,7 +202,10 @@ async function adoptConnection( ); } -async function handleGoogleAuth(success: GoogleSignInSuccess): Promise { +async function handleGoogleAuth( + success: GoogleSignInSuccess, + options?: { hasExistingSession?: boolean }, +): Promise { const { providerUser, oAuthTokens, @@ -237,6 +240,12 @@ async function handleGoogleAuth(success: GoogleSignInSuccess): Promise { switch (decision.authMode) { case "SIGNUP": { + if (options?.hasExistingSession) { + throw error( + AuthError.GoogleSignInWhileAuthenticated, + "You're already signed in — use Settings → Add account to connect this Google account.", + ); + } const isNewUser = createdNewRecipeUser && loginMethodsLength === 1; if (!isNewUser) { // Edge case: no Compass user found but SuperTokens says not new diff --git a/packages/backend/src/common/errors/auth/auth.errors.ts b/packages/backend/src/common/errors/auth/auth.errors.ts index 98c7f368f7..4c88492b4f 100644 --- a/packages/backend/src/common/errors/auth/auth.errors.ts +++ b/packages/backend/src/common/errors/auth/auth.errors.ts @@ -8,6 +8,7 @@ interface AuthErrors { GoogleNotConfigured: ErrorMetadata; GoogleRedirectUriMismatch: ErrorMetadata; GoogleRefreshTokenMissing: ErrorMetadata; + GoogleSignInWhileAuthenticated: ErrorMetadata; InadequatePermissions: ErrorMetadata; NoGAuthAccessToken: ErrorMetadata; SyncConnectionUnavailable: ErrorMetadata; @@ -50,6 +51,13 @@ export const AuthError: AuthErrors = { status: Status.CONFLICT, isOperational: true, }, + GoogleSignInWhileAuthenticated: { + code: "GOOGLE_SIGNIN_WHILE_AUTHENTICATED", + description: + "You're already signed in — use Settings → Add account to connect this Google account.", + status: Status.CONFLICT, + isOperational: true, + }, InadequatePermissions: { description: "You don't have permission to do that", status: Status.FORBIDDEN, diff --git a/packages/backend/src/common/middleware/supertokens.middleware.handlers.ts b/packages/backend/src/common/middleware/supertokens.middleware.handlers.ts index ecd0eaf9da..da5c27614c 100644 --- a/packages/backend/src/common/middleware/supertokens.middleware.handlers.ts +++ b/packages/backend/src/common/middleware/supertokens.middleware.handlers.ts @@ -125,7 +125,9 @@ export async function handleGoogleSignInUp( success, ); - await googleAuthService.handleGoogleAuth(remapped.success); + await googleAuthService.handleGoogleAuth(remapped.success, { + hasExistingSession: Boolean(input.session), + }); return remapped.response; } diff --git a/packages/scripts/src/commands/audit-connection-identity/audit.db.test.ts b/packages/scripts/src/commands/audit-connection-identity/audit.db.test.ts index 97f4ab6e14..f79ba97cb0 100644 --- a/packages/scripts/src/commands/audit-connection-identity/audit.db.test.ts +++ b/packages/scripts/src/commands/audit-connection-identity/audit.db.test.ts @@ -54,8 +54,6 @@ describe("auditConnectionIdentity (db)", () => { capabilities: ["readEvents", "readBusy", "writeEvents"], state: "healthy", stateReason: null, - lastSyncedAt: null, - lastHealthyAt: null, }); }; diff --git a/packages/sync/src/app.ts b/packages/sync/src/app.ts index b303b29d90..6fed782480 100644 --- a/packages/sync/src/app.ts +++ b/packages/sync/src/app.ts @@ -441,6 +441,7 @@ function buildSchedulers( resources, calendars: repos.calendars, connections: repos.connections, + credentials: repos.credentials, discovery: new GoogleCalendarAdapter(), commands: repos.commands, jobs, diff --git a/packages/sync/src/domain/busy-availability.service.db.test.ts b/packages/sync/src/domain/busy-availability.service.db.test.ts index ecb85ea0db..816e675d3c 100644 --- a/packages/sync/src/domain/busy-availability.service.db.test.ts +++ b/packages/sync/src/domain/busy-availability.service.db.test.ts @@ -54,9 +54,21 @@ describe("computeBusyAvailability", () => { capabilities: ["readEvents"], state, stateReason: null, - lastSyncedAt, - lastHealthyAt: lastSyncedAt, }); + if (lastSyncedAt !== null) { + await connections.updateDerivedState( + tenantId, + principalId, + connection._id, + { + state, + stateReason: null, + lastSyncedAt, + lastHealthyAt: lastSyncedAt, + }, + NOW, + ); + } return connection._id; }; diff --git a/packages/sync/src/domain/connection-refresh.service.db.test.ts b/packages/sync/src/domain/connection-refresh.service.db.test.ts index 705e864d56..390cf9a8eb 100644 --- a/packages/sync/src/domain/connection-refresh.service.db.test.ts +++ b/packages/sync/src/domain/connection-refresh.service.db.test.ts @@ -1,9 +1,12 @@ import { seedProviderCalendar } from "@sync/__tests__/helpers/fixtures"; import { setupSyncStorage } from "@sync/__tests__/helpers/storage"; import { refreshPrincipalCalendars } from "@sync/domain/connection-refresh.service"; +import { SYNC_COLLECTIONS } from "@sync/storage/collections"; +import { JOB_PRIORITY } from "@sync/storage/contracts/job.contracts"; import { type ProviderCalendarRecord } from "@sync/storage/contracts/provider-calendar.contracts"; import { JobRepository } from "@sync/storage/repositories/job.repository"; import { ProviderCalendarRepository } from "@sync/storage/repositories/provider-calendar.repository"; +import { ProviderConnectionRepository } from "@sync/storage/repositories/provider-connection.repository"; import { SyncResourceRepository } from "@sync/storage/repositories/sync-resource.repository"; const now = () => new Date("2026-08-10T12:00:00.000Z"); @@ -17,11 +20,12 @@ describe("refreshPrincipalCalendars (db)", () => { resources: new SyncResourceRepository(db), jobs: new JobRepository(db), calendars: new ProviderCalendarRepository(db), + connections: new ProviderConnectionRepository(db), }; }; it("revives a wedged failed repair job for the connection, not just incrementalPull", async () => { - const { resources, jobs, calendars } = deps(); + const { resources, jobs, calendars, connections } = deps(); const calendar: ProviderCalendarRecord = await seedProviderCalendar(calendars); const resource = await resources.ensure({ @@ -51,7 +55,7 @@ describe("refreshPrincipalCalendars (db)", () => { await jobs.fail(claimed._id, "worker-1"); const tally = await refreshPrincipalCalendars( - { resources, jobs }, + { resources, jobs, connections }, resource.tenantId, resource.principalId, now, @@ -66,4 +70,50 @@ describe("refreshPrincipalCalendars (db)", () => { expect(revived?.state).toBe("pending"); expect(revived?.attempt).toBe(0); }); + + it("enqueues a coalesced calendarListSync per connection at user priority", async () => { + const { resources, jobs, calendars, connections } = deps(); + const calendar: ProviderCalendarRecord = + await seedProviderCalendar(calendars); + await resources.ensure({ + tenantId: calendar.tenantId, + principalId: calendar.principalId, + connectionId: calendar.connectionId, + resourceKind: "events", + calendarId: calendar._id, + }); + + await refreshPrincipalCalendars( + { resources, jobs, connections }, + calendar.tenantId, + calendar.principalId, + now, + ); + + const discovery = await storage + .db() + .collection(SYNC_COLLECTIONS.jobs) + .findOne({ coalescingKey: `calendarListSync:${calendar.connectionId}` }); + expect(discovery).toMatchObject({ + kind: "calendarListSync", + resourceId: null, + coalescingKey: `calendarListSync:${calendar.connectionId}`, + priority: JOB_PRIORITY.user, + }); + + await refreshPrincipalCalendars( + { resources, jobs, connections }, + calendar.tenantId, + calendar.principalId, + now, + ); + expect( + await storage + .db() + .collection(SYNC_COLLECTIONS.jobs) + .countDocuments({ + coalescingKey: `calendarListSync:${calendar.connectionId}`, + }), + ).toBe(1); + }); }); diff --git a/packages/sync/src/domain/connection-refresh.service.test.ts b/packages/sync/src/domain/connection-refresh.service.test.ts index 132332404e..ad4819eeca 100644 --- a/packages/sync/src/domain/connection-refresh.service.test.ts +++ b/packages/sync/src/domain/connection-refresh.service.test.ts @@ -28,12 +28,14 @@ describe("refreshPrincipalCalendars", () => { ]; const requeueFailedByConnection = mock(async () => 0); + const listByPrincipal = mock(async () => [{ _id: "c1" }]); const tally = await refreshPrincipalCalendars( { resources: { listEventsByPrincipal: mock(async () => resources), } as never, jobs: { enqueueUrgent, requeueFailedByConnection } as never, + connections: { listByPrincipal } as never, }, "t1" as TenantId, "p1" as PrincipalId, @@ -49,8 +51,14 @@ describe("refreshPrincipalCalendars", () => { }); // One call per distinct connection touched, not per resource. expect(requeueFailedByConnection).toHaveBeenCalledTimes(1); - expect(enqueueUrgent).toHaveBeenCalledTimes(2); + expect(enqueueUrgent).toHaveBeenCalledTimes(3); expect(enqueueUrgent.mock.calls[0]?.[0]).toMatchObject({ + kind: "calendarListSync", + resourceId: null, + coalescingKey: "calendarListSync:c1", + priority: JOB_PRIORITY.user, + }); + expect(enqueueUrgent.mock.calls[1]?.[0]).toMatchObject({ kind: "incrementalPull", resourceId: "r1", coalescingKey: "incrementalPull:r1", @@ -66,10 +74,12 @@ describe("refreshPrincipalCalendars", () => { "inFlight", ] as const; let i = 0; - const enqueueUrgent = mock(async () => ({ - job: {}, - outcome: outcomes[i++]!, - })); + const enqueueUrgent = mock(async (job: { kind: string }) => { + if (job.kind === "calendarListSync") { + return { job: {}, outcome: "created" as const }; + } + return { job: {}, outcome: outcomes[i++]! }; + }); const resources = outcomes.map((id) => ({ _id: id, tenantId: "t1" as TenantId, @@ -84,6 +94,9 @@ describe("refreshPrincipalCalendars", () => { listEventsByPrincipal: mock(async () => resources), } as never, jobs: { enqueueUrgent, requeueFailedByConnection } as never, + connections: { + listByPrincipal: mock(async () => [{ _id: "c1" }]), + } as never, }, "t1" as TenantId, "p1" as PrincipalId, diff --git a/packages/sync/src/domain/connection-refresh.service.ts b/packages/sync/src/domain/connection-refresh.service.ts index 841a5792de..8761312808 100644 --- a/packages/sync/src/domain/connection-refresh.service.ts +++ b/packages/sync/src/domain/connection-refresh.service.ts @@ -7,11 +7,13 @@ import { type JobEnqueue, } from "@sync/storage/contracts/job.contracts"; import { type JobRepository } from "@sync/storage/repositories/job.repository"; +import { type ProviderConnectionRepository } from "@sync/storage/repositories/provider-connection.repository"; import { type SyncResourceRepository } from "@sync/storage/repositories/sync-resource.repository"; export interface ConnectionRefreshDeps { resources: SyncResourceRepository; jobs: JobRepository; + connections: Pick; } export type ConnectionRefreshTally = { @@ -36,10 +38,10 @@ export async function refreshPrincipalCalendars( principalId: PrincipalId, now: () => Date = () => new Date(), ): Promise { - const resources = await deps.resources.listEventsByPrincipal( - tenantId, - principalId, - ); + const [resources, connectionRecords] = await Promise.all([ + deps.resources.listEventsByPrincipal(tenantId, principalId), + deps.connections.listByPrincipal(tenantId, principalId), + ]); const runAfter = now(); const tally: ConnectionRefreshTally = { resources: resources.length, @@ -53,7 +55,15 @@ export async function refreshPrincipalCalendars( // touches, not just the incrementalPull rows enqueueUrgent below revives via // coalescing key. Otherwise a wedged calendarListSync/initialImport/repair/ // subscriptionMaintain row stays stuck even after the user asks to refresh. - const connectionIds = [...new Set(resources.map((r) => r.connectionId))]; + // Union connection rows with events-resource owners so a connection whose + // calendars have no events resource (the missing-resource trap) still + // requeues and re-runs discovery. + const connectionIds = [ + ...new Set([ + ...connectionRecords.map((connection) => connection._id), + ...resources.map((r) => r.connectionId), + ]), + ]; const revivedCounts = await Promise.all( connectionIds.map((connectionId) => deps.jobs.requeueFailedByConnection( @@ -66,6 +76,25 @@ export async function refreshPrincipalCalendars( ); tally.requeuedFailed += revivedCounts.reduce((sum, n) => sum + n, 0); + // Re-run calendar-list discovery per connection so a Refresh heals + // resource-less calendars (discovery creates the missing events resource + // and enqueues its import). Coalesced per connection at user priority. + await Promise.all( + connectionIds.map((connectionId) => + deps.jobs.enqueueUrgent({ + tenantId, + principalId, + connectionId, + resourceId: null, + commandId: null, + kind: "calendarListSync", + priority: JOB_PRIORITY.user, + runAfter, + coalescingKey: `calendarListSync:${connectionId}`, + }), + ), + ); + const outcomes = await Promise.all( resources.map(async (resource) => { const enqueue: JobEnqueue = { diff --git a/packages/sync/src/domain/connection-retention.service.db.test.ts b/packages/sync/src/domain/connection-retention.service.db.test.ts index 52e4f2a31d..dccee1cfc6 100644 --- a/packages/sync/src/domain/connection-retention.service.db.test.ts +++ b/packages/sync/src/domain/connection-retention.service.db.test.ts @@ -82,8 +82,6 @@ describe("purgeExpiredDisconnectedConnections", () => { capabilities: ["readEvents"], state: "healthy", stateReason: null, - lastSyncedAt: null, - lastHealthyAt: null, }); if (disconnectedAt) { await connections.markDisconnected( @@ -287,8 +285,6 @@ describe("purgeExpiredDisconnectedConnections", () => { capabilities: ["readEvents"], state: "healthy", stateReason: null, - lastSyncedAt: null, - lastHealthyAt: null, }); const purged = await purgeExpiredDisconnectedConnections( diff --git a/packages/sync/src/domain/connection-state-refresh.service.db.test.ts b/packages/sync/src/domain/connection-state-refresh.service.db.test.ts index e5a7ee8a62..6c3bb585e6 100644 --- a/packages/sync/src/domain/connection-state-refresh.service.db.test.ts +++ b/packages/sync/src/domain/connection-state-refresh.service.db.test.ts @@ -47,8 +47,6 @@ describe("refreshConnectionState", () => { capabilities: ["readEvents", "readBusy", "writeEvents"], state: "importing", stateReason: null, - lastSyncedAt: null, - lastHealthyAt: null, }); await credentials.store({ connectionId: connection._id, @@ -424,6 +422,92 @@ describe("refreshConnectionState", () => { expect(after.stateReason).toBe("workOverdue"); }); + it("reports delayed when an active calendar has no events resource and is past the stall window", async () => { + const connection = await seedImportingConnection(); + await calendars.upsertByProviderCalendar({ + tenantId: connection.tenantId, + principalId: connection.principalId, + connectionId: connection._id, + providerCalendarId: "primary@example.com" as ProviderCalendarSourceId, + displayName: "Primary", + color: null, + active: true, + primary: true, + accessRole: "owner", + capabilities: { + canReadEvents: true, + canWriteEvents: true, + canReadBusy: true, + canInviteAttendees: true, + }, + }); + const listResource = await resources.ensure({ + tenantId: connection.tenantId, + principalId: connection.principalId, + connectionId: connection._id, + resourceKind: "calendarList", + calendarId: null, + }); + await resources.advanceCursor( + connection.tenantId, + connection.principalId, + listResource._id, + "list-cursor", + new Date(), + ); + + const stillFresh = await refreshConnectionState(deps(), connection); + expect(stillFresh.state).toBe("importing"); + + const wellPastOverdue = () => + new Date(Date.now() + BOOTSTRAP_STALLED_AFTER_MS); + const after = await refreshConnectionState( + deps(), + connection, + wellPastOverdue, + ); + expect(after.state).toBe("delayed"); + expect(after.stateReason).toBe("workOverdue"); + }); + + it("keeps importing when an active calendar has no events resource and is still young", async () => { + const connection = await seedImportingConnection(); + await calendars.upsertByProviderCalendar({ + tenantId: connection.tenantId, + principalId: connection.principalId, + connectionId: connection._id, + providerCalendarId: "primary@example.com" as ProviderCalendarSourceId, + displayName: "Primary", + color: null, + active: true, + primary: true, + accessRole: "owner", + capabilities: { + canReadEvents: true, + canWriteEvents: true, + canReadBusy: true, + canInviteAttendees: true, + }, + }); + const listResource = await resources.ensure({ + tenantId: connection.tenantId, + principalId: connection.principalId, + connectionId: connection._id, + resourceKind: "calendarList", + calendarId: null, + }); + await resources.advanceCursor( + connection.tenantId, + connection.principalId, + listResource._id, + "list-cursor", + new Date(), + ); + + const after = await refreshConnectionState(deps(), connection); + expect(after.state).toBe("importing"); + }); + it("reports catchingUp while a user-requested pull is queued", async () => { const connection = await seedImportingConnection(); const calendar = await calendars.upsertByProviderCalendar({ diff --git a/packages/sync/src/domain/connection-state-refresh.service.ts b/packages/sync/src/domain/connection-state-refresh.service.ts index 78679585d0..6f125e1f6f 100644 --- a/packages/sync/src/domain/connection-state-refresh.service.ts +++ b/packages/sync/src/domain/connection-state-refresh.service.ts @@ -131,10 +131,13 @@ export async function gatherConnectionStateEvidence( // this just for taking a while. const bootstrapOverdue = activeCalendars.some((c) => { const resource = eventsByCalendar.get(c._id); - if (!resource || resource.bootstrapState === "ready") return false; - return ( - now.getTime() - resource.updatedAt.getTime() >= BOOTSTRAP_STALLED_AFTER_MS - ); + if (resource?.bootstrapState === "ready") return false; + // An active calendar with no events resource used to skip this check + // entirely and stay "importing" forever. Fall back to the calendar row's + // own updatedAt so a missing resource still trips delayed after the stall + // window; Refresh then re-runs calendar-list discovery to heal it. + const basis = resource?.updatedAt ?? c.updatedAt; + return now.getTime() - basis.getTime() >= BOOTSTRAP_STALLED_AFTER_MS; }); // "Last synced" must be as old as the least-recent active calendar. Taking diff --git a/packages/sync/src/domain/sync-job-dispatch.service.db.test.ts b/packages/sync/src/domain/sync-job-dispatch.service.db.test.ts index acb8f555c0..56d5966bc5 100644 --- a/packages/sync/src/domain/sync-job-dispatch.service.db.test.ts +++ b/packages/sync/src/domain/sync-job-dispatch.service.db.test.ts @@ -27,11 +27,13 @@ import { type ProviderCalendarRecord } from "@sync/storage/contracts/provider-ca import { type ProviderConnectionRecord } from "@sync/storage/contracts/provider-connection.contracts"; import { type SyncResourceRecord } from "@sync/storage/contracts/sync-resource.contracts"; import { CommandRepository } from "@sync/storage/repositories/command.repository"; +import { CredentialRepository } from "@sync/storage/repositories/credential.repository"; import { EventRepository } from "@sync/storage/repositories/event.repository"; import { EventOccurrenceRepository } from "@sync/storage/repositories/event-occurrence.repository"; import { InvalidationRepository } from "@sync/storage/repositories/invalidation.repository"; import { JobRepository } from "@sync/storage/repositories/job.repository"; import { ProviderCalendarRepository } from "@sync/storage/repositories/provider-calendar.repository"; +import { ProviderConnectionRepository } from "@sync/storage/repositories/provider-connection.repository"; import { SyncResourceRepository } from "@sync/storage/repositories/sync-resource.repository"; const objectId = () => faker.database.mongodbObjectId(); @@ -151,6 +153,7 @@ describe("dispatchSyncJob", () => { let commands: CommandRepository; let jobs: JobRepository; let invalidations: InvalidationRepository; + let credentials: CredentialRepository; // Dispatch resolves the connection for a calendarListSync job; a test sets what // findById returns without seeding a full connection record. let stubbedConnection: ProviderConnectionRecord | null; @@ -163,11 +166,27 @@ describe("dispatchSyncJob", () => { commands = new CommandRepository(storage.db()); jobs = new JobRepository(storage.db()); invalidations = new InvalidationRepository(storage.db()); + credentials = new CredentialRepository(storage.db()); stubbedConnection = null; }); const connections = { findById: async () => stubbedConnection, + updateDerivedState: async ( + tenantId: ProviderConnectionRecord["tenantId"], + principalId: ProviderConnectionRecord["principalId"], + id: ProviderConnectionRecord["_id"], + fields: { + state: ProviderConnectionRecord["state"]; + stateReason: ProviderConnectionRecord["stateReason"]; + lastSyncedAt: Date | null; + lastHealthyAt: Date | null; + }, + at?: Date, + ) => { + const real = new ProviderConnectionRepository(storage.db()); + return real.updateDerivedState(tenantId, principalId, id, fields, at); + }, } as unknown as SyncJobDispatchDeps["connections"]; const deps = ( @@ -181,6 +200,7 @@ describe("dispatchSyncJob", () => { resources, calendars, connections, + credentials, discovery: discoveryOverride, jobs, commands, @@ -731,6 +751,135 @@ describe("dispatchSyncJob", () => { expect(saved?.subscriptionId).toBeNull(); }); + async function seedConnectedCalendar() { + const realConnections = new ProviderConnectionRepository(storage.db()); + const connection = await realConnections.upsertByProviderAccount({ + tenantId: objectId(), + principalId: objectId(), + provider: "google", + account: { + providerAccountId: "acct-1", + email: "user@example.com", + displayName: "User", + }, + capabilities: ["readEvents", "readBusy", "writeEvents"], + state: "importing", + stateReason: null, + }); + await credentials.store({ + connectionId: connection._id, + provider: "google", + refreshToken: "refresh", + scopes: ["https://www.googleapis.com/auth/calendar.events"], + }); + const calendar = await seedProviderCalendar(calendars, { + tenantId: connection.tenantId, + principalId: connection.principalId, + connectionId: connection._id, + }); + const listResource = await resources.ensure({ + tenantId: connection.tenantId, + principalId: connection.principalId, + connectionId: connection._id, + resourceKind: "calendarList", + calendarId: null, + }); + await resources.advanceCursor( + connection.tenantId, + connection.principalId, + listResource._id, + "list-cursor", + now(), + ); + stubbedConnection = connection; + return { connection, calendar }; + } + + async function connectionInvalidations(principalId: string) { + const feed = await storage + .db() + .collection(SYNC_COLLECTIONS.invalidations) + .find({ principalId }) + .toArray(); + return feed.filter( + (row) => + (row.invalidation as { kind?: string } | undefined)?.kind === + "connection", + ); + } + + it("appends a connection invalidation when bootstrapCatchup completes", async () => { + const { connection, calendar } = await seedConnectedCalendar(); + const resource = await seedResource(calendar, "cursor-1"); + + const outcome = await dispatchSyncJob( + deps(new FakeReader([page([], "cursor-2")])), + jobFor(resource, "bootstrapCatchup"), + now, + ); + + expect(outcome).toEqual({ result: "done" }); + expect(await connectionInvalidations(connection.principalId)).toHaveLength( + 1, + ); + }); + + it("appends a connection invalidation when subscriptionMaintain completes unsupported", async () => { + const { connection, calendar } = await seedConnectedCalendar(); + const resource = await ensureEventsResource(resources, calendar, { + cursor: "cursor-1", + bootstrapState: "watching", + now, + }); + const refusing = { + ...notifications, + watchEvents: async () => { + throw new ProviderNotificationError( + "watchUnsupported", + "push not supported for this calendar", + ); + }, + }; + + const outcome = await dispatchSyncJob( + deps(new FakeReader([]), tokenSource, refusing), + jobFor(resource, "subscriptionMaintain"), + now, + ); + + expect(outcome).toEqual({ result: "done" }); + expect(await connectionInvalidations(connection.principalId)).toHaveLength( + 1, + ); + }); + + it("does not fail the job when connection-state refresh throws", async () => { + const { calendar } = await seedConnectedCalendar(); + const resource = await seedResource(calendar, "cursor-1"); + const warnings: string[] = []; + const failingDeps: SyncJobDispatchDeps = { + ...deps(new FakeReader([page([], "cursor-2")])), + connections: { + findById: async () => stubbedConnection, + updateDerivedState: async () => { + throw new Error("derived-state write failed"); + }, + } as unknown as SyncJobDispatchDeps["connections"], + log: { warn: (message) => warnings.push(message) }, + }; + + const outcome = await dispatchSyncJob( + failingDeps, + jobFor(resource, "bootstrapCatchup"), + now, + ); + + expect(outcome).toEqual({ result: "done" }); + expect(warnings.some((message) => message.includes("derived-state"))).toBe( + true, + ); + }); + const calendarListJob = ( connectionId: string, tenantId: string, diff --git a/packages/sync/src/domain/sync-job-dispatch.service.ts b/packages/sync/src/domain/sync-job-dispatch.service.ts index 06b47de766..e4230134fc 100644 --- a/packages/sync/src/domain/sync-job-dispatch.service.ts +++ b/packages/sync/src/domain/sync-job-dispatch.service.ts @@ -2,6 +2,7 @@ import { importCalendarEvents } from "@sync/domain/calendar-import.service"; import { syncCalendarList } from "@sync/domain/calendar-list-sync.service"; import { pullCalendarChanges } from "@sync/domain/calendar-pull.service"; import { repairCalendar } from "@sync/domain/calendar-repair.service"; +import { refreshConnectionState } from "@sync/domain/connection-state-refresh.service"; import { type AccessTokenSource } from "@sync/domain/provider-write-ladder"; import { maintainSubscription } from "@sync/domain/subscription-maintenance.service"; import { ProviderAuthError } from "@sync/providers/provider-auth.port"; @@ -22,6 +23,7 @@ import { import { type ProviderCalendarRecord } from "@sync/storage/contracts/provider-calendar.contracts"; import { type SyncResourceRecord } from "@sync/storage/contracts/sync-resource.contracts"; import { type CommandRepository } from "@sync/storage/repositories/command.repository"; +import { type CredentialRepository } from "@sync/storage/repositories/credential.repository"; import { type EventRepository } from "@sync/storage/repositories/event.repository"; import { type EventOccurrenceRepository } from "@sync/storage/repositories/event-occurrence.repository"; import { type InvalidationRepository } from "@sync/storage/repositories/invalidation.repository"; @@ -38,6 +40,7 @@ export interface SyncJobDispatchDeps { // syncCalendarList resolves the connection a calendarListSync job targets, // discovers its calendars, and enqueues an initial import per active calendar. connections: ProviderConnectionRepository; + credentials: CredentialRepository; discovery: ProviderCalendarAdapter; jobs: JobRepository; // pullCalendarChanges consults pending commands before deleting an event. @@ -334,6 +337,7 @@ async function runSyncJob( "ready", ); await appendCalendarInvalidation(deps, calendar, now()); + await refreshConnectionStateAfterBootstrap(deps, job); return { result: "done" }; } if (pull.status === "notImported") { @@ -394,6 +398,7 @@ async function runSyncJob( "ready", ); await appendCalendarInvalidation(deps, calendar, now()); + await refreshConnectionStateAfterBootstrap(deps, job); return { result: "done" }; } if (subscription.status !== "authRevoked") { @@ -468,6 +473,31 @@ function needsBootstrapCompletion(resource: SyncResourceRecord): boolean { return resource.bootstrapState !== "ready"; } +// Push derived connection state after bootstrap evidence changes. At dispatch +// time the current job is still claimed, so derivation lands on catchingUp +// (still a change from importing → invalidation fires → client refetches → +// the read-path refresh after the job settles lands healthy). Never fail the +// job: state refresh is observational, not part of the provider work. +async function refreshConnectionStateAfterBootstrap( + deps: SyncJobDispatchDeps, + job: JobRecord, +): Promise { + try { + const connection = await deps.connections.findById( + job.tenantId, + job.principalId, + job.connectionId, + ); + if (!connection) return; + await refreshConnectionState(deps, connection); + } catch (error) { + const detail = error instanceof Error ? error.message : String(error); + deps.log?.warn( + `Failed to refresh connection state after bootstrap for connection ${job.connectionId}: ${detail}`, + ); + } +} + async function appendCalendarInvalidation( deps: Pick, calendar: ProviderCalendarRecord, diff --git a/packages/sync/src/domain/sync-job-worker.service.db.test.ts b/packages/sync/src/domain/sync-job-worker.service.db.test.ts index a9e1eac678..c963f7d0ff 100644 --- a/packages/sync/src/domain/sync-job-worker.service.db.test.ts +++ b/packages/sync/src/domain/sync-job-worker.service.db.test.ts @@ -21,6 +21,7 @@ import { type JobRecord } from "@sync/storage/contracts/job.contracts"; import { type ProviderCalendarRecord } from "@sync/storage/contracts/provider-calendar.contracts"; import { type SyncResourceRecord } from "@sync/storage/contracts/sync-resource.contracts"; import { CommandRepository } from "@sync/storage/repositories/command.repository"; +import { CredentialRepository } from "@sync/storage/repositories/credential.repository"; import { EventRepository } from "@sync/storage/repositories/event.repository"; import { EventOccurrenceRepository } from "@sync/storage/repositories/event-occurrence.repository"; import { InvalidationRepository } from "@sync/storage/repositories/invalidation.repository"; @@ -128,6 +129,7 @@ describe("SyncJobWorker", () => { resources, calendars, connections, + credentials: new CredentialRepository(storage.db()), discovery: discoveryOverride, commands, jobs, @@ -600,8 +602,6 @@ describe("SyncJobWorker", () => { capabilities: ["readEvents", "readBusy", "writeEvents"], state: "importing", stateReason: null, - lastSyncedAt: null, - lastHealthyAt: null, }); const job = await jobs.enqueue({ tenantId: connection.tenantId, diff --git a/packages/sync/src/safety/isolation-matrix.db.test.ts b/packages/sync/src/safety/isolation-matrix.db.test.ts index 60db17c707..fadfb61b68 100644 --- a/packages/sync/src/safety/isolation-matrix.db.test.ts +++ b/packages/sync/src/safety/isolation-matrix.db.test.ts @@ -112,8 +112,6 @@ describe("R-SEC-03 isolation matrix", () => { capabilities: ["readEvents"], state: "healthy", stateReason: null, - lastSyncedAt: new Date(), - lastHealthyAt: new Date(), }); return connection._id; }; diff --git a/packages/sync/src/server/availability.routes.db.test.ts b/packages/sync/src/server/availability.routes.db.test.ts index 73906ba19a..950b0ca6d7 100644 --- a/packages/sync/src/server/availability.routes.db.test.ts +++ b/packages/sync/src/server/availability.routes.db.test.ts @@ -93,8 +93,6 @@ describe("POST /internal/availability/busy", () => { capabilities: ["readEvents"], state, stateReason: null, - lastSyncedAt: new Date(), - lastHealthyAt: new Date(), }); return c._id; }; diff --git a/packages/sync/src/server/connection.routes.db.test.ts b/packages/sync/src/server/connection.routes.db.test.ts index ed88deaa32..594768103b 100644 --- a/packages/sync/src/server/connection.routes.db.test.ts +++ b/packages/sync/src/server/connection.routes.db.test.ts @@ -139,8 +139,6 @@ const seedConnection = ( capabilities: ["readEvents"], state: "healthy", stateReason: null, - lastSyncedAt: null, - lastHealthyAt: null, }); // Sign like the trusted Compass API would, so the request clears internal auth. @@ -794,6 +792,19 @@ describe("GET /sync/google", () => { principalId, "reauth@example.com", ); + const healthyAt = new Date("2026-07-01T00:00:00.000Z"); + await connections.updateDerivedState( + existing.tenantId, + existing.principalId, + existing._id, + { + state: "healthy", + stateReason: null, + lastSyncedAt: healthyAt, + lastHealthyAt: healthyAt, + }, + healthyAt, + ); // Google returns the same account the connection is tied to. adapter.exchangeResult = { ...adapter.exchangeResult, @@ -817,6 +828,7 @@ describe("GET /sync/google", () => { ); expect(all).toHaveLength(1); expect(all[0]._id).toBe(existing._id); + expect(all[0].lastHealthyAt).toEqual(healthyAt); }); it("refuses and links nothing when reconnect consents with a different account", async () => { diff --git a/packages/sync/src/server/connection.routes.ts b/packages/sync/src/server/connection.routes.ts index 1be04b47b8..2d4e416439 100644 --- a/packages/sync/src/server/connection.routes.ts +++ b/packages/sync/src/server/connection.routes.ts @@ -584,7 +584,11 @@ export function registerConnectionRoutes( try { const repos = syncRepositories(deps.mongo); const tally = await refreshPrincipalCalendars( - { resources: repos.syncResources, jobs: repos.jobs }, + { + resources: repos.syncResources, + jobs: repos.jobs, + connections: repos.connections, + }, auth.tenantId, auth.principalId, () => new Date((deps.now ?? Date.now)()), @@ -819,8 +823,6 @@ async function linkConnection( capabilities: googleCapabilitiesFromScopes(authorization.grantedScopes), state: derived.state, stateReason: derived.reason, - lastSyncedAt: null, - lastHealthyAt: null, }); try { diff --git a/packages/sync/src/server/diagnostic.routes.db.test.ts b/packages/sync/src/server/diagnostic.routes.db.test.ts index 5497f7f534..033e6255ab 100644 --- a/packages/sync/src/server/diagnostic.routes.db.test.ts +++ b/packages/sync/src/server/diagnostic.routes.db.test.ts @@ -85,8 +85,6 @@ describe("GET /internal/diagnostics/connections/:diagnosticKey", () => { capabilities: ["readEvents"], state: "delayed", stateReason: null, - lastSyncedAt: null, - lastHealthyAt: null, }); await calendars.upsertByProviderCalendar({ tenantId: tenantId as TenantId, diff --git a/packages/sync/src/server/principal.routes.db.test.ts b/packages/sync/src/server/principal.routes.db.test.ts index 12dd24d561..7333dfc371 100644 --- a/packages/sync/src/server/principal.routes.db.test.ts +++ b/packages/sync/src/server/principal.routes.db.test.ts @@ -95,8 +95,6 @@ const seedConnection = ( capabilities: ["readEvents"], state: "healthy", stateReason: null, - lastSyncedAt: null, - lastHealthyAt: null, }); describe("DELETE /internal/principal", () => { diff --git a/packages/sync/src/storage/contracts/provider-connection.contracts.ts b/packages/sync/src/storage/contracts/provider-connection.contracts.ts index 6bd794fa8b..e809ac8085 100644 --- a/packages/sync/src/storage/contracts/provider-connection.contracts.ts +++ b/packages/sync/src/storage/contracts/provider-connection.contracts.ts @@ -76,8 +76,6 @@ export const ProviderConnectionUpsertSchema = z capabilities: ProviderCapabilitySetSchema, state: ConnectionStateSchema, stateReason: ConnectionStateReasonSchema.nullable(), - lastSyncedAt: z.date().nullable(), - lastHealthyAt: z.date().nullable(), }) .refine(hasReasonWhenActionRequired, actionRequiredReasonIssue); export type ProviderConnectionUpsert = z.infer< diff --git a/packages/sync/src/storage/repositories/provider-connection.repository.db.test.ts b/packages/sync/src/storage/repositories/provider-connection.repository.db.test.ts index 75fc32c9d6..fa07e1779e 100644 --- a/packages/sync/src/storage/repositories/provider-connection.repository.db.test.ts +++ b/packages/sync/src/storage/repositories/provider-connection.repository.db.test.ts @@ -22,8 +22,6 @@ const baseUpsert = ( capabilities: ["readEvents"], state: "importing", stateReason: null, - lastSyncedAt: null, - lastHealthyAt: null, ...overrides, }) as ProviderConnectionUpsert; @@ -142,6 +140,36 @@ describe("ProviderConnectionRepository", () => { ]); }); + it("keeps prior lastHealthyAt when the same account reconnects", async () => { + const tenantId = objectId(); + const principalId = objectId(); + const created = await repo.upsertByProviderAccount( + baseUpsert({ tenantId, principalId }), + ); + const healthyAt = new Date("2026-07-01T00:00:00.000Z"); + await repo.updateDerivedState( + created.tenantId, + created.principalId, + created._id, + { + state: "healthy", + stateReason: null, + lastSyncedAt: healthyAt, + lastHealthyAt: healthyAt, + }, + healthyAt, + ); + + const reconnected = await repo.upsertByProviderAccount( + baseUpsert({ tenantId, principalId, state: "importing" }), + ); + + expect(reconnected._id).toBe(created._id); + expect(reconnected.state).toBe("importing"); + expect(reconnected.lastHealthyAt).toEqual(healthyAt); + expect(reconnected.lastSyncedAt).toEqual(healthyAt); + }); + it("does not leak one principal's connections to another", async () => { const tenantId = objectId(); const mine = objectId(); diff --git a/packages/sync/src/storage/repositories/provider-connection.repository.ts b/packages/sync/src/storage/repositories/provider-connection.repository.ts index 556001a78e..eeaa6eddaa 100644 --- a/packages/sync/src/storage/repositories/provider-connection.repository.ts +++ b/packages/sync/src/storage/repositories/provider-connection.repository.ts @@ -53,8 +53,6 @@ export class ProviderConnectionRepository { capabilities: fields.capabilities, state: fields.state, stateReason: fields.stateReason, - lastSyncedAt: fields.lastSyncedAt, - lastHealthyAt: fields.lastHealthyAt, // A successful upsert-by-account-identity means the account is live — // a create or a reconnect — so clear any prior disconnect evidence. // This keeps disconnectedAt and state consistent: an upsert never @@ -68,6 +66,11 @@ export class ProviderConnectionRepository { tenantId: fields.tenantId, principalId: fields.principalId, provider: fields.provider, + // Health timestamps are derived evidence, not link-time facts. + // Putting them on $set would clobber lastHealthyAt on reconnect and + // make an established account look like a first import. + lastSyncedAt: null, + lastHealthyAt: null, createdAt: now, }, }, diff --git a/packages/sync/src/telemetry/health-snapshot.service.db.test.ts b/packages/sync/src/telemetry/health-snapshot.service.db.test.ts index f695790595..75f3aa9f3a 100644 --- a/packages/sync/src/telemetry/health-snapshot.service.db.test.ts +++ b/packages/sync/src/telemetry/health-snapshot.service.db.test.ts @@ -60,8 +60,6 @@ describe("computeHealthSnapshot", () => { capabilities: ["readEvents"], state, stateReason: state === "actionRequired" ? "authorizationRevoked" : null, - lastSyncedAt: null, - lastHealthyAt: null, }); it("aggregates connection states, jobs, subscriptions, and freshness", async () => { diff --git a/packages/web/src/auth/compass/user/util/user-metadata.util.test.ts b/packages/web/src/auth/compass/user/util/user-metadata.util.test.ts index 6cb5b78a68..c91a4ff36c 100644 --- a/packages/web/src/auth/compass/user/util/user-metadata.util.test.ts +++ b/packages/web/src/auth/compass/user/util/user-metadata.util.test.ts @@ -1,10 +1,14 @@ import { type UserMetadata } from "@core/types/user.types"; import { createTestToastPort } from "@web/__tests__/helpers/web-test-seams"; +import { UserApi } from "@web/api/user.api"; import { resetGoogleReconnectRequiredForTests } from "@web/auth/google/state/google.reconnect.state"; import { GOOGLE_DELAYED_TOAST_ID } from "@web/common/constants/toast.constants"; import { registerToastPort } from "@web/common/utils/toast/toast.port"; -import { applyUserMetadataSideEffects } from "./user-metadata.util"; -import { beforeEach, describe, expect, it } from "bun:test"; +import { + applyUserMetadataSideEffects, + refreshUserMetadata, +} from "./user-metadata.util"; +import { beforeEach, describe, expect, it, spyOn } from "bun:test"; const healthy: UserMetadata = { google: { connectionState: "HEALTHY" } }; const attention: UserMetadata = { google: { connectionState: "ATTENTION" } }; @@ -64,3 +68,25 @@ describe("applyUserMetadataSideEffects - delayed toast lifecycle", () => { ); }); }); + +describe("refreshUserMetadata force coalescing", () => { + it("chains concurrent force calls onto one trailing fetch", async () => { + let resolveFirst!: (value: UserMetadata) => void; + const first = new Promise((resolve) => { + resolveFirst = resolve; + }); + const getMetadata = spyOn(UserApi, "getMetadata") + .mockImplementationOnce(() => first) + .mockResolvedValue(healthy); + + const inFlight = refreshUserMetadata(); + const forceA = refreshUserMetadata({ force: true }); + const forceB = refreshUserMetadata({ force: true }); + + resolveFirst(attention); + await Promise.all([inFlight, forceA, forceB]); + + expect(getMetadata).toHaveBeenCalledTimes(2); + getMetadata.mockRestore(); + }); +}); diff --git a/packages/web/src/auth/compass/user/util/user-metadata.util.ts b/packages/web/src/auth/compass/user/util/user-metadata.util.ts index f93951ebac..d7c63966c1 100644 --- a/packages/web/src/auth/compass/user/util/user-metadata.util.ts +++ b/packages/web/src/auth/compass/user/util/user-metadata.util.ts @@ -23,6 +23,7 @@ import { let refreshUserMetadataRequest: Promise | null = null; let hasShownDelayedToastThisLoad = false; +let metadataFetchEpoch = 0; /** * Keep session reconnect overrides and sticky toasts congruent with the latest @@ -85,16 +86,19 @@ export const refreshUserMetadata = async (options?: { return refreshUserMetadataRequest; } - // The in-flight request predates whatever invalidated the metadata (e.g. - // a Google revocation prune), so its response is stale. Let it settle, - // then fetch fresh. + // Concurrent force calls chain onto one trailing fetch: each waits for + // the in-flight request, then `refreshUserMetadata()` without force joins + // whichever fetch the first waiter already started. A burst of SSE + // signals cannot stampede. return refreshUserMetadataRequest.then(() => refreshUserMetadata()); } userMetadataActions.setLoading(); + const epoch = ++metadataFetchEpoch; refreshUserMetadataRequest = UserApi.getMetadata() .then((metadata) => { + if (epoch !== metadataFetchEpoch) return; userMetadataActions.set(metadata); applyUserMetadataSideEffects(metadata); }) diff --git a/packages/web/src/auth/google/hooks/useConnectGoogle/useConnectGoogle.scope.test.tsx b/packages/web/src/auth/google/hooks/useConnectGoogle/useConnectGoogle.scope.test.tsx index e4bb348c81..bcc6c61203 100644 --- a/packages/web/src/auth/google/hooks/useConnectGoogle/useConnectGoogle.scope.test.tsx +++ b/packages/web/src/auth/google/hooks/useConnectGoogle/useConnectGoogle.scope.test.tsx @@ -114,4 +114,23 @@ describe("useConnectGoogle account scoping", () => { beginSpy.mockRestore(); }); + + it("never sends connectionId for a new-account connect, even under RECONNECT_REQUIRED", async () => { + const beginSpy = spyOn(AuthApi, "beginGoogleConnection").mockResolvedValue({ + authorizationUrl: "#consent", + }); + + const { wrapper } = createStoreWrapper(); + const { result } = renderHook( + () => useConnectGoogle({ newAccount: true }), + { wrapper }, + ); + act(() => result.current.connect()); + + await waitFor(() => { + expect(beginSpy).toHaveBeenCalledWith({}); + }); + + beginSpy.mockRestore(); + }); }); diff --git a/packages/web/src/auth/google/hooks/useConnectGoogle/useConnectGoogle.ts b/packages/web/src/auth/google/hooks/useConnectGoogle/useConnectGoogle.ts index c98fb1cce8..2dba921042 100644 --- a/packages/web/src/auth/google/hooks/useConnectGoogle/useConnectGoogle.ts +++ b/packages/web/src/auth/google/hooks/useConnectGoogle/useConnectGoogle.ts @@ -36,6 +36,12 @@ export interface UseConnectGoogleOptions { * the precedence-winning one. Omit for the aggregate (whole-user) view. */ connection?: GoogleSyncConnectionSummary | null; + /** + * Always start a new-account OAuth round-trip (`{}`), even when some other + * account is `RECONNECT_REQUIRED`. Settings "Add account" must never bind + * to a reconnect. + */ + newAccount?: boolean; } export const useConnectGoogle = ( @@ -124,9 +130,10 @@ export const useConnectGoogle = ( // from metadata so the wrong account cannot spawn a second. try { const beginRequest = - state === "RECONNECT_REQUIRED" && syncConnection?.id - ? { connectionId: syncConnection.id as ConnectionId } - : {}; + options?.newAccount || + !(state === "RECONNECT_REQUIRED" && syncConnection?.id) + ? {} + : { connectionId: syncConnection.id as ConnectionId }; const { authorizationUrl } = await AuthApi.beginGoogleConnection(beginRequest); window.location.assign(authorizationUrl); @@ -140,7 +147,7 @@ export const useConnectGoogle = ( }; void start(); - }, [state, stopConnecting, syncConnection?.id]); + }, [options?.newAccount, state, stopConnecting, syncConnection?.id]); const onRefreshGoogle = useCallback( (options?: { silent?: boolean }) => { diff --git a/packages/web/src/calendars/calendar.util.test.ts b/packages/web/src/calendars/calendar.util.test.ts index a67c898cc9..a06aa35554 100644 --- a/packages/web/src/calendars/calendar.util.test.ts +++ b/packages/web/src/calendars/calendar.util.test.ts @@ -449,9 +449,11 @@ describe("groupCalendarsByAccount", () => { expect(groups[0]?.calendars).toEqual([work]); }); - it("omits a connected account with no calendars yet", () => { + it("keeps a connected account with no calendars yet", () => { const { groups } = groupCalendarsByAccount([], [connection("old@x.com")]); - expect(groups).toHaveLength(0); + expect(groups).toHaveLength(1); + expect(groups[0]?.accountEmail).toBe("old@x.com"); + expect(groups[0]?.calendars).toEqual([]); }); }); diff --git a/packages/web/src/calendars/calendar.util.ts b/packages/web/src/calendars/calendar.util.ts index bc4a5fe5b5..9631022aca 100644 --- a/packages/web/src/calendars/calendar.util.ts +++ b/packages/web/src/calendars/calendar.util.ts @@ -119,8 +119,9 @@ export interface AccountGroup { /** * Bucket calendars by the account they belong to, in connection order, with * anything lacking an account email (the local calendar) left ungrouped. - * Callers render `ungrouped` after the groups, matching {@link - * compareCalendars}, which sorts accountless calendars last. + * Empty groups are kept so a connected-but-still-importing account still + * renders its section header. Callers that cannot show an empty optgroup + * (the Settings default-calendar select) skip those groups inline. */ export function groupCalendarsByAccount( calendars: Calendar[], @@ -157,9 +158,7 @@ export function groupCalendarsByAccount( group.calendars.push(calendar); } - // An account can be connected before its calendars have imported; an empty - // section would render a heading with nothing under it. - return { groups: groups.filter((g) => g.calendars.length > 0), ungrouped }; + return { groups, ungrouped }; } export interface DefaultTargetCalendarOptions { diff --git a/packages/web/src/components/AuthModal/AuthModal.test.tsx b/packages/web/src/components/AuthModal/AuthModal.test.tsx index 1fd5f7e0b8..46bfba20bb 100644 --- a/packages/web/src/components/AuthModal/AuthModal.test.tsx +++ b/packages/web/src/components/AuthModal/AuthModal.test.tsx @@ -18,6 +18,7 @@ import { resetEmailPasswordPort, } from "@web/auth/compass/hooks/emailpassword.port"; import { registerUseCompleteAuthenticationForTests } from "@web/auth/compass/hooks/useCompleteAuthentication.registry"; +import { SessionContext } from "@web/auth/compass/session/session.context"; import { markGoogleAuthNeedsConsentRetry } from "@web/auth/google/authorization/google-authorization.storage"; import { registerUseStartGoogleAuthorizationForTests } from "@web/auth/google/authorization/useStartGoogleAuthorization"; import { @@ -859,6 +860,30 @@ describe("URL Parameter Support", () => { }); }); + it("ignores ?auth= while a session already exists", async () => { + const router = createTestRouter( + {} }} + > + + + + , + { initialEntries: ["/?auth=login"] }, + ); + render(); + await waitForRouterIdle(router); + + expect( + screen.queryByRole("heading", { name: /hey, welcome back/i }), + ).not.toBeInTheDocument(); + await waitFor(() => { + expect(router.state.location.search).not.toEqual( + expect.objectContaining({ auth: "login" }), + ); + }); + }); + it("opens sign up modal when ?auth=signup is present", async () => { await renderWithProviders(
, "/?auth=signup"); diff --git a/packages/web/src/components/AuthModal/hooks/useAuthModal.ts b/packages/web/src/components/AuthModal/hooks/useAuthModal.ts index 80d733e8f7..453c08f64d 100644 --- a/packages/web/src/components/AuthModal/hooks/useAuthModal.ts +++ b/packages/web/src/components/AuthModal/hooks/useAuthModal.ts @@ -1,5 +1,12 @@ import { useNavigate, useSearch } from "@tanstack/react-router"; -import { createContext, useCallback, useContext, useMemo } from "react"; +import { + createContext, + useCallback, + useContext, + useEffect, + useMemo, +} from "react"; +import { useSession } from "@web/auth/compass/session/useSession"; export type AuthView = | "login" @@ -81,6 +88,7 @@ function getViewFromParam(param: string | undefined): AuthView | null { export function useAuthModalState(): AuthModalContextValue { const { auth } = useSearch({ from: "__root__" }); const navigate = useNavigate(); + const { authenticated } = useSession(); const view = getViewFromParam(auth); const setAuthView = useCallback( @@ -100,14 +108,23 @@ export function useAuthModalState(): AuthModalContextValue { [navigate, view], ); + useEffect(() => { + if (!authenticated || !auth) return; + navigate({ + to: ".", + replace: true, + search: (prev) => ({ ...prev, auth: undefined }), + }); + }, [authenticated, auth, navigate]); + return useMemo( () => ({ - isOpen: view !== null, + isOpen: view !== null && !authenticated, currentView: view ?? "login", openModal: (nextView: AuthView = "login") => setAuthView(nextView), closeModal: () => setAuthView(null), setView: (nextView: AuthView) => setAuthView(nextView), }), - [view, setAuthView], + [authenticated, view, setAuthView], ); } diff --git a/packages/web/src/components/Settings/SettingsModal.tsx b/packages/web/src/components/Settings/SettingsModal.tsx index 639898f31d..d60b36b2e9 100644 --- a/packages/web/src/components/Settings/SettingsModal.tsx +++ b/packages/web/src/components/Settings/SettingsModal.tsx @@ -212,15 +212,17 @@ const DefaultCalendarPicker: FC = ({ onChange={(e) => setDefaultCalendarId(e.target.value as CalendarId)} value={value} > - {groups.map((group) => ( - - {group.calendars.map((calendar) => ( - - ))} - - ))} + {groups + .filter((group) => group.calendars.length > 0) + .map((group) => ( + + {group.calendars.map((calendar) => ( + + ))} + + ))} {ungrouped.map((calendar) => (
- ) : calendars.length === 0 ? ( + ) : calendars.length === 0 && groups.length === 0 ? (

{authenticated && state === "NOT_CONNECTED" && isAvailable ? "Connect Google to see your calendars." @@ -128,22 +115,43 @@ export const CalendarList: FC = () => { ))} {ungrouped.length > 0 ? ( -

    - {ungrouped.map((calendar) => - isAnonymous ? ( - - ) : ( - - ), - )} -
+ groups.length > 0 && email ? ( +
+
+

+ + {email} + +

+
+ {renderRows(ungrouped)} +
+ ) : ( +
    + {ungrouped.map((calendar) => + isAnonymous ? ( + + ) : ( + + ), + )} +
+ ) ) : null} )} diff --git a/packages/web/src/sse/hooks/useGcalSSE.factory.ts b/packages/web/src/sse/hooks/useGcalSSE.factory.ts index 71510f77ca..cf60a401ad 100644 --- a/packages/web/src/sse/hooks/useGcalSSE.factory.ts +++ b/packages/web/src/sse/hooks/useGcalSSE.factory.ts @@ -20,7 +20,9 @@ export type GcalSSEDependencies = { handleGoogleRevoked: () => void; invalidateEventQueries: () => void; onServerMessage: OnServerMessage; - refreshUserMetadata: () => Promise | unknown; + refreshUserMetadata: (options?: { + force?: boolean; + }) => Promise | unknown; setUserMetadata: (metadata: UserMetadata) => void; showErrorToast: ( message: string | undefined, @@ -43,7 +45,7 @@ export const createUseGcalSSE = (dependencies: GcalSSEDependencies) => { } if (message.sync.status === "healthy") { - void dependencies.refreshUserMetadata(); + void dependencies.refreshUserMetadata({ force: true }); return; } @@ -55,7 +57,7 @@ export const createUseGcalSSE = (dependencies: GcalSSEDependencies) => { return; } - void dependencies.refreshUserMetadata(); + void dependencies.refreshUserMetadata({ force: true }); if (message.sync.code === "WATCH_REPAIR_FAILED") { dependencies.showErrorToast( @@ -68,14 +70,14 @@ export const createUseGcalSSE = (dependencies: GcalSSEDependencies) => { }, []); const onImportCompleted = useCallback((_message: ImportResultMessage) => { - void dependencies.refreshUserMetadata(); + void dependencies.refreshUserMetadata({ force: true }); dependencies.invalidateEventQueries(); }, []); // Sync connection/calendar invalidations arrive as calendarsChanged. Refetch // metadata so IMPORTING → HEALTHY (or RECONNECT/ATTENTION) reaches the UI. const onCalendarsChanged = useCallback(() => { - void dependencies.refreshUserMetadata(); + void dependencies.refreshUserMetadata({ force: true }); }, []); const onUserMetadataChanged = useCallback( diff --git a/packages/web/src/sse/hooks/useSSEConnection.test.tsx b/packages/web/src/sse/hooks/useSSEConnection.test.tsx index 0582fb598c..85e356b5b5 100644 --- a/packages/web/src/sse/hooks/useSSEConnection.test.tsx +++ b/packages/web/src/sse/hooks/useSSEConnection.test.tsx @@ -7,6 +7,7 @@ import { beforeEach, describe, expect, it, mock } from "bun:test"; const mockUseSession = mock(); const mockUseUser = mock(); +const mockRefreshUserMetadata = mock().mockResolvedValue(undefined); const openStream = mock(); const closeStream = mock(); let reopenHandler: (() => void) | null = null; @@ -23,6 +24,9 @@ mock.module("@web/auth/compass/session/useSession", () => ({ mock.module("@web/auth/compass/user/hooks/useUser", () => ({ useUser: mockUseUser, })); +mock.module("@web/auth/compass/user/util/user-metadata.util", () => ({ + refreshUserMetadata: mockRefreshUserMetadata, +})); mock.module("../client/sse.client", () => ({ openStream, closeStream, @@ -43,6 +47,7 @@ describe("useSSEConnection", () => { openStream.mockClear(); closeStream.mockClear(); onStreamReopen.mockClear(); + mockRefreshUserMetadata.mockClear(); mockUseSession.mockReturnValue({ authenticated: true, setAuthenticated: mock(), @@ -75,5 +80,6 @@ describe("useSSEConnection", () => { expect(invalidateSpy).toHaveBeenCalledWith({ queryKey: calendarQueryKeys.all, }); + expect(mockRefreshUserMetadata).toHaveBeenCalledWith({ force: true }); }); }); diff --git a/packages/web/src/sse/hooks/useSSEConnection.ts b/packages/web/src/sse/hooks/useSSEConnection.ts index a5f4851958..0fe807ec3b 100644 --- a/packages/web/src/sse/hooks/useSSEConnection.ts +++ b/packages/web/src/sse/hooks/useSSEConnection.ts @@ -2,6 +2,7 @@ import { useQueryClient } from "@tanstack/react-query"; import { useEffect } from "react"; import { useSession } from "@web/auth/compass/session/useSession"; import { useUser } from "@web/auth/compass/user/hooks/useUser"; +import { refreshUserMetadata } from "@web/auth/compass/user/util/user-metadata.util"; import { calendarQueryKeys } from "@web/calendars/calendar.query"; import { eventQueryKeys } from "@web/events/queries/event.query.keys"; import { closeStream, onStreamReopen, openStream } from "../client/sse.client"; @@ -13,6 +14,15 @@ const invalidateScheduleQueries = ( void queryClient.invalidateQueries({ queryKey: calendarQueryKeys.all }); }; +const reconcileAfterStreamGap = ( + queryClient: ReturnType, +) => { + invalidateScheduleQueries(queryClient); + // A reopened stream means missed invalidations; events+calendars already + // refetch, metadata is the third leg. + void refreshUserMetadata({ force: true }); +}; + export const useSSEConnection = () => { const { authenticated } = useSession(); const { userId } = useUser(); @@ -26,9 +36,9 @@ export const useSSEConnection = () => { openStream(); // Open and native reconnect can both follow a disconnect gap. - invalidateScheduleQueries(queryClient); + reconcileAfterStreamGap(queryClient); return onStreamReopen(() => { - invalidateScheduleQueries(queryClient); + reconcileAfterStreamGap(queryClient); }); }, [authenticated, userId, queryClient]); }; diff --git a/packages/web/src/sse/hooks/useSyncFocusRefresh.test.ts b/packages/web/src/sse/hooks/useSyncFocusRefresh.test.ts index 307c9556b8..0ae84b46e0 100644 --- a/packages/web/src/sse/hooks/useSyncFocusRefresh.test.ts +++ b/packages/web/src/sse/hooks/useSyncFocusRefresh.test.ts @@ -1,10 +1,25 @@ import { renderHook } from "@testing-library/react"; import { act } from "react"; import { type UseConnectGoogleResult } from "@web/auth/google/hooks/useConnectGoogle/useConnectGoogle.types"; -import { useSyncFocusRefresh } from "@web/sse/hooks/useSyncFocusRefresh"; -import { afterEach, describe, expect, it, mock, setSystemTime } from "bun:test"; +import { + afterEach, + beforeEach, + describe, + expect, + it, + mock, + setSystemTime, +} from "bun:test"; const MIN_HIDDEN_DURATION_MS = 30_000; +const mockRefreshUserMetadata = mock().mockResolvedValue(undefined); + +mock.module("@web/auth/compass/user/util/user-metadata.util", () => ({ + refreshUserMetadata: mockRefreshUserMetadata, +})); + +const { useSyncFocusRefresh } = + require("./useSyncFocusRefresh") as typeof import("./useSyncFocusRefresh"); const fakeConnectGoogle = ( overrides: Partial = {}, @@ -32,6 +47,10 @@ describe("useSyncFocusRefresh", () => { document.dispatchEvent(new Event("visibilitychange")); }; + beforeEach(() => { + mockRefreshUserMetadata.mockClear(); + }); + afterEach(() => { visibilityState = "visible"; setSystemTime(); @@ -43,6 +62,7 @@ describe("useSyncFocusRefresh", () => { expect(refresh).toHaveBeenCalledTimes(1); expect(refresh).toHaveBeenCalledWith({ silent: true }); + expect(mockRefreshUserMetadata).toHaveBeenCalledWith({ force: true }); }); it("refreshes on mount for an ATTENTION connection", () => { @@ -54,22 +74,24 @@ describe("useSyncFocusRefresh", () => { ); expect(refresh).toHaveBeenCalledTimes(1); + expect(mockRefreshUserMetadata).toHaveBeenCalledWith({ force: true }); }); it.each([ "NOT_CONNECTED", "RECONNECT_REQUIRED", "IMPORTING", - ] as const)("does not refresh on mount for %s", (state) => { + ] as const)("does not provider-refresh on mount for %s", (state) => { const refresh = mock(); renderHook(() => useSyncFocusRefresh(() => fakeConnectGoogle({ refresh, state })), ); expect(refresh).not.toHaveBeenCalled(); + expect(mockRefreshUserMetadata).toHaveBeenCalledWith({ force: true }); }); - it("does not refresh when Google is unavailable", () => { + it("does not provider-refresh when Google is unavailable", () => { const refresh = mock(); renderHook(() => useSyncFocusRefresh(() => @@ -78,6 +100,7 @@ describe("useSyncFocusRefresh", () => { ); expect(refresh).not.toHaveBeenCalled(); + expect(mockRefreshUserMetadata).toHaveBeenCalledWith({ force: true }); }); it("does not retrigger when shared refresh status changes", () => { @@ -115,6 +138,7 @@ describe("useSyncFocusRefresh", () => { const refresh = mock(); renderHook(() => useSyncFocusRefresh(() => fakeConnectGoogle({ refresh }))); refresh.mockClear(); + mockRefreshUserMetadata.mockClear(); act(() => { setVisibility("hidden"); @@ -124,6 +148,28 @@ describe("useSyncFocusRefresh", () => { expect(refresh).toHaveBeenCalledTimes(1); expect(refresh).toHaveBeenCalledWith({ silent: true }); + expect(mockRefreshUserMetadata).toHaveBeenCalledWith({ force: true }); + }); + + it("refreshes metadata but not provider refresh on focus during IMPORTING", () => { + setSystemTime(new Date("2026-02-05T00:00:00.000Z")); + const refresh = mock(); + renderHook(() => + useSyncFocusRefresh(() => + fakeConnectGoogle({ refresh, state: "IMPORTING" }), + ), + ); + refresh.mockClear(); + mockRefreshUserMetadata.mockClear(); + + act(() => { + setVisibility("hidden"); + setSystemTime(new Date(Date.now() + MIN_HIDDEN_DURATION_MS + 1_000)); + setVisibility("visible"); + }); + + expect(refresh).not.toHaveBeenCalled(); + expect(mockRefreshUserMetadata).toHaveBeenCalledWith({ force: true }); }); it("does not refresh on a short hide", () => { @@ -131,6 +177,7 @@ describe("useSyncFocusRefresh", () => { const refresh = mock(); renderHook(() => useSyncFocusRefresh(() => fakeConnectGoogle({ refresh }))); refresh.mockClear(); + mockRefreshUserMetadata.mockClear(); act(() => { setVisibility("hidden"); @@ -139,5 +186,6 @@ describe("useSyncFocusRefresh", () => { }); expect(refresh).not.toHaveBeenCalled(); + expect(mockRefreshUserMetadata).not.toHaveBeenCalled(); }); }); diff --git a/packages/web/src/sse/hooks/useSyncFocusRefresh.ts b/packages/web/src/sse/hooks/useSyncFocusRefresh.ts index f728add66c..6e7345d577 100644 --- a/packages/web/src/sse/hooks/useSyncFocusRefresh.ts +++ b/packages/web/src/sse/hooks/useSyncFocusRefresh.ts @@ -1,4 +1,5 @@ import { useCallback, useEffect, useRef } from "react"; +import { refreshUserMetadata } from "@web/auth/compass/user/util/user-metadata.util"; import { useConnectGoogle } from "@web/auth/google/hooks/useConnectGoogle/useConnectGoogle"; import { type UseConnectGoogleResult } from "@web/auth/google/hooks/useConnectGoogle/useConnectGoogle.types"; import { useVisibleAfterHidden } from "@web/common/hooks/useVisibleAfterHidden"; @@ -19,8 +20,10 @@ const MIN_HIDDEN_DURATION_MS = 30_000; * browser-wide refresh coordinator, so a focus refresh and a manual click * share work and status instead of racing each other. * - * No-ops while there's no established connection worth refreshing (not yet - * connected, reconnect required, or still on the initial import). + * Provider refresh no-ops while there's no established connection worth + * refreshing (not yet connected, reconnect required, or still on the initial + * import). Metadata always reconciles on mount and on visible-after-hidden — + * including during IMPORTING, which is exactly when the UI used to get stuck. * * `useConnectGoogleImpl` is a test seam (default: the real hook) so tests can * pass a fake implementation instead of mock.module-ing a hook other files @@ -30,19 +33,30 @@ export const useSyncFocusRefresh = ( useConnectGoogleImpl: () => UseConnectGoogleResult = useConnectGoogle, ) => { const { isAvailable, refresh, state } = useConnectGoogleImpl(); - const didRefreshOnMount = useRef(false); + const didReconcileMetadataOnMount = useRef(false); + const didProviderRefreshOnMount = useRef(false); const canRefresh = isAvailable && (state === "HEALTHY" || state === "ATTENTION"); const silentRefresh = useCallback(() => refresh({ silent: true }), [refresh]); + const reconcileMetadata = useCallback(() => { + void refreshUserMetadata({ force: true }); + }, []); useEffect(() => { - if (!canRefresh || didRefreshOnMount.current) return; + if (didReconcileMetadataOnMount.current) return; + didReconcileMetadataOnMount.current = true; + reconcileMetadata(); + }, [reconcileMetadata]); + + useEffect(() => { + if (!canRefresh || didProviderRefreshOnMount.current) return; // Metadata briefly changes the connection state while a refresh is // requested. A mount refresh must not run again when that state settles, // or every completion enqueues another pull forever. - didRefreshOnMount.current = true; + didProviderRefreshOnMount.current = true; silentRefresh(); }, [canRefresh, silentRefresh]); useVisibleAfterHidden(silentRefresh, MIN_HIDDEN_DURATION_MS, canRefresh); + useVisibleAfterHidden(reconcileMetadata, MIN_HIDDEN_DURATION_MS, true); }; diff --git a/packages/web/src/sse/hooks/useTransientSyncPolling.test.ts b/packages/web/src/sse/hooks/useTransientSyncPolling.test.ts new file mode 100644 index 0000000000..aa7595d139 --- /dev/null +++ b/packages/web/src/sse/hooks/useTransientSyncPolling.test.ts @@ -0,0 +1,127 @@ +import { renderHook } from "@testing-library/react"; +import { act } from "react"; +import { userMetadataActions } from "@web/auth/state/user-metadata.store"; +import { + afterEach, + beforeEach, + describe, + expect, + it, + mock, + spyOn, +} from "bun:test"; + +const mockRefreshUserMetadata = mock().mockResolvedValue(undefined); + +mock.module("@web/auth/compass/user/util/user-metadata.util", () => ({ + refreshUserMetadata: mockRefreshUserMetadata, +})); + +const { useTransientSyncPolling } = + require("./useTransientSyncPolling") as typeof import("./useTransientSyncPolling"); + +const TRANSIENT_POLL_MS = 20_000; + +describe("useTransientSyncPolling", () => { + let intervalCallback: (() => void) | undefined; + let setIntervalSpy: ReturnType; + let clearIntervalSpy: ReturnType; + + beforeEach(() => { + intervalCallback = undefined; + mockRefreshUserMetadata.mockClear(); + setIntervalSpy = spyOn(globalThis, "setInterval").mockImplementation((( + callback: TimerHandler, + ) => { + if (typeof callback === "function") { + intervalCallback = () => callback(); + } + return 1 as unknown as ReturnType; + }) as unknown as typeof setInterval); + clearIntervalSpy = spyOn(globalThis, "clearInterval").mockImplementation( + () => {}, + ); + }); + + afterEach(() => { + setIntervalSpy.mockRestore(); + clearIntervalSpy.mockRestore(); + userMetadataActions.clear(); + }); + + it("polls metadata while a connection is importing and stops when it settles", () => { + userMetadataActions.set({ + google: { + connectionState: "IMPORTING", + connections: [ + { + id: "c1", + state: "importing", + stateReason: null, + lastSyncedAt: null, + lastHealthyAt: null, + accountEmail: "a@example.com", + connectionState: "IMPORTING", + }, + ], + }, + }); + + const hook = renderHook(() => useTransientSyncPolling()); + + expect(setIntervalSpy).toHaveBeenCalledWith( + expect.any(Function), + TRANSIENT_POLL_MS, + ); + + act(() => { + intervalCallback?.(); + }); + expect(mockRefreshUserMetadata).toHaveBeenCalledWith({ force: true }); + + act(() => { + userMetadataActions.set({ + google: { + connectionState: "HEALTHY", + connections: [ + { + id: "c1", + state: "healthy", + stateReason: null, + lastSyncedAt: null, + lastHealthyAt: null, + accountEmail: "a@example.com", + connectionState: "HEALTHY", + }, + ], + }, + }); + }); + hook.rerender(); + + expect(clearIntervalSpy).toHaveBeenCalled(); + }); + + it("does not poll when no connection is transient", () => { + userMetadataActions.set({ + google: { + connectionState: "HEALTHY", + connections: [ + { + id: "c1", + state: "healthy", + stateReason: null, + lastSyncedAt: null, + lastHealthyAt: null, + accountEmail: "a@example.com", + connectionState: "HEALTHY", + }, + ], + }, + }); + + renderHook(() => useTransientSyncPolling()); + + expect(setIntervalSpy).not.toHaveBeenCalled(); + }); +}); diff --git a/packages/web/src/sse/hooks/useTransientSyncPolling.ts b/packages/web/src/sse/hooks/useTransientSyncPolling.ts new file mode 100644 index 0000000000..a4dcca1395 --- /dev/null +++ b/packages/web/src/sse/hooks/useTransientSyncPolling.ts @@ -0,0 +1,34 @@ +import { useEffect } from "react"; +import { refreshUserMetadata } from "@web/auth/compass/user/util/user-metadata.util"; +import { + selectGoogleSyncConnections, + useUserMetadataStore, +} from "@web/auth/state/user-metadata.store"; + +const TRANSIENT_CONNECTION_STATES = new Set([ + "connecting", + "importing", + "catchingUp", +]); +const TRANSIENT_POLL_MS = 20_000; + +/** + * While any single connection (not the aggregate) is still connecting, + * importing, or catching up, force-refresh metadata every 20s. Each poll is + * also a server-side re-derivation because the sync read path refreshes + * state. Stops when nothing is transient. + */ +export const useTransientSyncPolling = () => { + const connections = useUserMetadataStore(selectGoogleSyncConnections); + const anyTransient = connections.some((connection) => + TRANSIENT_CONNECTION_STATES.has(connection.state), + ); + + useEffect(() => { + if (!anyTransient) return; + const id = window.setInterval(() => { + void refreshUserMetadata({ force: true }); + }, TRANSIENT_POLL_MS); + return () => window.clearInterval(id); + }, [anyTransient]); +}; diff --git a/packages/web/src/sse/provider/SSEProvider.interaction.test.tsx b/packages/web/src/sse/provider/SSEProvider.interaction.test.tsx index 5a9c56cc81..0cd49afa39 100644 --- a/packages/web/src/sse/provider/SSEProvider.interaction.test.tsx +++ b/packages/web/src/sse/provider/SSEProvider.interaction.test.tsx @@ -157,7 +157,7 @@ describe("useGcalSSE", () => { // importCompleted alone never clears the override; only metadata that // leaves IMPORTING (or an attention/healthy syncStatusChanged) does. expect(getGoogleSyncIndicatorOverride()).toBe("syncing"); - expect(refreshUserMetadata).toHaveBeenCalled(); + expect(refreshUserMetadata).toHaveBeenCalledWith({ force: true }); expect(mockInvalidateEventQueries).toHaveBeenCalled(); }); }); @@ -170,7 +170,7 @@ describe("useGcalSSE", () => { }); await waitFor(() => { - expect(refreshUserMetadata).toHaveBeenCalled(); + expect(refreshUserMetadata).toHaveBeenCalledWith({ force: true }); }); }); @@ -184,7 +184,7 @@ describe("useGcalSSE", () => { await waitFor(() => { expect(getGoogleSyncIndicatorOverride()).toBe("syncing"); - expect(refreshUserMetadata).toHaveBeenCalled(); + expect(refreshUserMetadata).toHaveBeenCalledWith({ force: true }); }); }); diff --git a/packages/web/src/sse/provider/SSEProvider.tsx b/packages/web/src/sse/provider/SSEProvider.tsx index 1aa66e0726..f46e7ebcb2 100644 --- a/packages/web/src/sse/provider/SSEProvider.tsx +++ b/packages/web/src/sse/provider/SSEProvider.tsx @@ -3,6 +3,7 @@ import { useEventSSE } from "../hooks/useEventSSE"; import { useGcalSSE } from "../hooks/useGcalSSE"; import { useSSEConnection } from "../hooks/useSSEConnection"; import { useSyncFocusRefresh } from "../hooks/useSyncFocusRefresh"; +import { useTransientSyncPolling } from "../hooks/useTransientSyncPolling"; export * from "../client/sse.client"; @@ -11,6 +12,7 @@ const SSEProvider = ({ children }: { children: ReactNode }) => { useEventSSE(); useGcalSSE(); useSyncFocusRefresh(); + useTransientSyncPolling(); return children; }; diff --git a/packages/web/src/views/Day/components/Calendar/dayCalendarColumns.util.test.ts b/packages/web/src/views/Day/components/Calendar/dayCalendarColumns.util.test.ts index 93909a9413..62f96f07c8 100644 --- a/packages/web/src/views/Day/components/Calendar/dayCalendarColumns.util.test.ts +++ b/packages/web/src/views/Day/components/Calendar/dayCalendarColumns.util.test.ts @@ -27,7 +27,7 @@ function makeCalendar(overrides: Partial): Calendar { } describe("getDayViewCalendars", () => { - it("keeps the local calendar when no account is connected", () => { + it("keeps the local calendar when it is active and visible", () => { const local = makeCalendar({ provider: "local", name: "Compass" }); const google = makeCalendar({ provider: "google", @@ -38,20 +38,7 @@ describe("getDayViewCalendars", () => { expect(getDayViewCalendars([local, google])).toEqual([local, google]); }); - it("drops the local calendar once an account is connected", () => { - const local = makeCalendar({ provider: "local", name: "Compass" }); - const google = makeCalendar({ - provider: "google", - id: "507f1f77bcf86cd799439012" as Calendar["id"], - name: "primary", - }); - - expect( - getDayViewCalendars([local, google], { hasConnectedAccount: true }), - ).toEqual([google]); - }); - - it("falls back to the primary among remaining calendars when none are visible", () => { + it("falls back to the primary calendar when none are visible", () => { const local = makeCalendar({ provider: "local", name: "Compass", @@ -72,22 +59,22 @@ describe("getDayViewCalendars", () => { }); expect( - getDayViewCalendars([local, secondaryGoogle, primaryGoogle], { - hasConnectedAccount: true, - }), + getDayViewCalendars([local, secondaryGoogle, primaryGoogle]), ).toEqual([primaryGoogle]); }); - it("does not fall back to the local calendar when an account is connected", () => { + it("includes a visible local column alongside Google calendars", () => { const local = makeCalendar({ provider: "local", name: "Compass", isPrimary: true, - isVisible: false, + }); + const google = makeCalendar({ + provider: "google", + id: "507f1f77bcf86cd799439012" as Calendar["id"], + name: "primary", }); - expect(getDayViewCalendars([local], { hasConnectedAccount: true })).toEqual( - [], - ); + expect(getDayViewCalendars([local, google])).toEqual([local, google]); }); }); diff --git a/packages/web/src/views/Day/components/Calendar/dayCalendarColumns.util.ts b/packages/web/src/views/Day/components/Calendar/dayCalendarColumns.util.ts index e30aa9ff2f..7c3f43f50f 100644 --- a/packages/web/src/views/Day/components/Calendar/dayCalendarColumns.util.ts +++ b/packages/web/src/views/Day/components/Calendar/dayCalendarColumns.util.ts @@ -1,37 +1,14 @@ import { type Calendar } from "@core/types/calendar.contracts"; -export interface GetDayViewCalendarsOptions { - /** - * True once any account is connected. Mirrors the sidebar (LCV3): the local - * Compass calendar then drops out of day-view columns so connected users do - * not see an orphan empty column they can no longer toggle in the list. - * Derive from connection state (e.g. useConnectedAccountEmails().length > 0), - * never from calendar rows alone. - */ - hasConnectedAccount?: boolean; -} - -export const getDayViewCalendars = ( - calendars: Calendar[], - options: GetDayViewCalendarsOptions = {}, -): Calendar[] => { - const { hasConnectedAccount = false } = options; +export const getDayViewCalendars = (calendars: Calendar[]): Calendar[] => { const eligibleCalendars = calendars.filter( - (calendar) => - (!hasConnectedAccount || calendar.provider !== "local") && - calendar.isActive && - calendar.isVisible, + (calendar) => calendar.isActive && calendar.isVisible, ); if (eligibleCalendars.length > 0) { return eligibleCalendars; } - const fallbackCalendars = hasConnectedAccount - ? calendars.filter((calendar) => calendar.provider !== "local") - : calendars; - const primaryCalendar = fallbackCalendars.find( - (calendar) => calendar.isPrimary, - ); - return primaryCalendar ? [primaryCalendar] : fallbackCalendars.slice(0, 1); + const primaryCalendar = calendars.find((calendar) => calendar.isPrimary); + return primaryCalendar ? [primaryCalendar] : calendars.slice(0, 1); }; diff --git a/packages/web/src/views/Day/components/Calendar/useDayCalendarColumns.ts b/packages/web/src/views/Day/components/Calendar/useDayCalendarColumns.ts index 1367b00a9e..f329ba4e79 100644 --- a/packages/web/src/views/Day/components/Calendar/useDayCalendarColumns.ts +++ b/packages/web/src/views/Day/components/Calendar/useDayCalendarColumns.ts @@ -2,7 +2,6 @@ import { useCallback, useMemo } from "react"; import { YEAR_MONTH_DAY_FORMAT } from "@core/constants/date.constants"; import { type Dayjs } from "@core/util/date/dayjs"; import { useCalendarsQuery } from "@web/calendars/calendar.query"; -import { useConnectedAccountEmails } from "@web/calendars/useDefaultTargetCalendar"; import { type GridEvent } from "@web/common/types/web.event.types"; import { isAllDayEventOnDay } from "./dayAllDayRows.util"; import { getDayViewCalendars } from "./dayCalendarColumns.util"; @@ -17,10 +16,9 @@ export const useDayCalendarColumns = ({ timedEvents: GridEvent[]; }) => { const { data: calendars = [] } = useCalendarsQuery(); - const hasConnectedAccount = useConnectedAccountEmails().length > 0; const displayedCalendars = useMemo( - () => getDayViewCalendars(calendars, { hasConnectedAccount }), - [calendars, hasConnectedAccount], + () => getDayViewCalendars(calendars), + [calendars], ); const calendarColumnIndexById = useMemo( () => From 6e6548f9985d8c588182db6b8f7801d32e6371f7 Mon Sep 17 00:00:00 2001 From: Cursor Agent Date: Thu, 13 Aug 2026 00:14:20 +0000 Subject: [PATCH 2/7] test(web): isolate AuthModal session mock from leaked useSession URL-param tests timed out because a process-wide useSession mock from another file reported authenticated, so ?auth= never opened the modal. Co-authored-by: Tyler Dane --- .../components/AuthModal/AuthModal.test.tsx | 40 +++++++++++++------ 1 file changed, 27 insertions(+), 13 deletions(-) diff --git a/packages/web/src/components/AuthModal/AuthModal.test.tsx b/packages/web/src/components/AuthModal/AuthModal.test.tsx index 46bfba20bb..9f0c90362a 100644 --- a/packages/web/src/components/AuthModal/AuthModal.test.tsx +++ b/packages/web/src/components/AuthModal/AuthModal.test.tsx @@ -18,7 +18,6 @@ import { resetEmailPasswordPort, } from "@web/auth/compass/hooks/emailpassword.port"; import { registerUseCompleteAuthenticationForTests } from "@web/auth/compass/hooks/useCompleteAuthentication.registry"; -import { SessionContext } from "@web/auth/compass/session/session.context"; import { markGoogleAuthNeedsConsentRetry } from "@web/auth/google/authorization/google-authorization.storage"; import { registerUseStartGoogleAuthorizationForTests } from "@web/auth/google/authorization/useStartGoogleAuthorization"; import { @@ -38,6 +37,20 @@ const mockUseStartGoogleAuthorization = mock(() => ({ const mockCompleteAuthentication = mock().mockResolvedValue(undefined); let mockEmailPassword = createTestEmailPasswordPort(); +// mock.module is process-wide. Capture the real hook and flip the flag off in +// afterAll so later files do not inherit this file's unauthenticated default. +const actualUseSession = (await import("@web/auth/compass/session/useSession")) + .useSession; +const isSessionMocked = true; +const mockUseSession = mock(() => ({ + authenticated: false, + userId: undefined as string | undefined, +})); +mock.module("@web/auth/compass/session/useSession", () => ({ + useSession: (...args: Parameters) => + isSessionMocked ? mockUseSession(...args) : actualUseSession(...args), +})); + const { redirectToToday, loadTodayData } = await import("@web/routers/loaders"); const { ROOT_ROUTES } = await import("@web/common/constants/routes"); @@ -134,6 +147,10 @@ function installAuthModalTestSeams() { registerEmailPasswordPort(mockEmailPassword); resetGoogleAvailabilityForTests(); setGoogleAvailabilityForTests("available"); + mockUseSession.mockReset().mockReturnValue({ + authenticated: false, + userId: undefined, + }); } describe("AuthModal", () => { @@ -861,18 +878,11 @@ describe("URL Parameter Support", () => { }); it("ignores ?auth= while a session already exists", async () => { - const router = createTestRouter( - {} }} - > - - - - , - { initialEntries: ["/?auth=login"] }, - ); - render(); - await waitForRouterIdle(router); + mockUseSession.mockReturnValue({ + authenticated: true, + userId: "user-1", + }); + const { router } = await renderWithProviders(
, "/?auth=login"); expect( screen.queryByRole("heading", { name: /hey, welcome back/i }), @@ -1072,3 +1082,7 @@ describe("URL Parameter Support", () => { }); }); }); + +afterAll(() => { + isSessionMocked = false; +}); From 75132521c70139ee0955b971fcb3adc1a69ed866 Mon Sep 17 00:00:00 2001 From: Cursor Agent Date: Thu, 13 Aug 2026 00:14:54 +0000 Subject: [PATCH 3/7] test(web): allow AuthModal session mock restore flag to flip Co-authored-by: Tyler Dane --- packages/web/src/components/AuthModal/AuthModal.test.tsx | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/packages/web/src/components/AuthModal/AuthModal.test.tsx b/packages/web/src/components/AuthModal/AuthModal.test.tsx index 9f0c90362a..e0846cd625 100644 --- a/packages/web/src/components/AuthModal/AuthModal.test.tsx +++ b/packages/web/src/components/AuthModal/AuthModal.test.tsx @@ -41,7 +41,7 @@ let mockEmailPassword = createTestEmailPasswordPort(); // afterAll so later files do not inherit this file's unauthenticated default. const actualUseSession = (await import("@web/auth/compass/session/useSession")) .useSession; -const isSessionMocked = true; +let isSessionMocked = true; const mockUseSession = mock(() => ({ authenticated: false, userId: undefined as string | undefined, From 9c06b0341a1a5e47679a6b7062f14fde39342308 Mon Sep 17 00:00:00 2001 From: Cursor Agent Date: Thu, 13 Aug 2026 00:17:43 +0000 Subject: [PATCH 4/7] test: fix metadata, polling, and google sign-in assertions Spy the shared BaseApi and window timers so process-wide module mocks cannot hide the coalescing and import-poll behavior, and assert the new hasExistingSession argument on handleGoogleAuth. Co-authored-by: Tyler Dane --- .../middleware/supertokens.middleware.test.ts | 38 ++++++++++++++++++- .../user/util/user-metadata.util.test.ts | 29 +++++++++----- .../sse/hooks/useTransientSyncPolling.test.ts | 6 +-- 3 files changed, 60 insertions(+), 13 deletions(-) diff --git a/packages/backend/src/common/middleware/supertokens.middleware.test.ts b/packages/backend/src/common/middleware/supertokens.middleware.test.ts index 81846c1732..2596ff8978 100644 --- a/packages/backend/src/common/middleware/supertokens.middleware.test.ts +++ b/packages/backend/src/common/middleware/supertokens.middleware.test.ts @@ -1,5 +1,4 @@ import * as corsLib from "cors"; -import { ObjectId } from "mongodb"; import superTokensNode from "supertokens-node"; import Dashboard from "supertokens-node/recipe/dashboard"; import EmailPassword from "supertokens-node/recipe/emailpassword"; @@ -464,6 +463,42 @@ describe("supertokens.middleware", () => { }); expect(googleAuthService.handleGoogleAuth).toHaveBeenCalledWith( successPayload, + { hasExistingSession: false }, + ); + }); + + it("tells handleGoogleAuth when ThirdParty signInUpPOST already has a session", async () => { + const responsePayload = { status: "OK" }; + const successPayload = { providerUser: { id: "u1" } }; + const existingSession = { getUserId: () => "existing-user" }; + + (createGoogleSignInSuccess as Mock).mockReturnValue(successPayload); + + initSupertokens(); + + const thirdPartyConfig = getFirstCallArg<{ + override: { + apis: (originalImplementation: { + signInUpPOST?: (input: unknown) => Promise; + }) => { + signInUpPOST: (input: unknown) => Promise; + }; + }; + }>(ThirdParty.init); + + const originalImplementation = { + signInUpPOST: mock().mockResolvedValue(responsePayload), + }; + + const overridden = thirdPartyConfig.override.apis(originalImplementation); + const input = { session: existingSession, some: "input" }; + + await overridden.signInUpPOST(input); + + expect(originalImplementation.signInUpPOST).toHaveBeenCalledWith(input); + expect(googleAuthService.handleGoogleAuth).toHaveBeenCalledWith( + successPayload, + { hasExistingSession: true }, ); }); @@ -663,6 +698,7 @@ describe("supertokens.middleware", () => { expect(createGoogleSignInSuccess).toHaveBeenCalledTimes(2); expect(googleAuthService.handleGoogleAuth).toHaveBeenCalledWith( expect.objectContaining({ recipeUserId: "compass-user-id" }), + { hasExistingSession: false }, ); }); diff --git a/packages/web/src/auth/compass/user/util/user-metadata.util.test.ts b/packages/web/src/auth/compass/user/util/user-metadata.util.test.ts index c91a4ff36c..3866f47a81 100644 --- a/packages/web/src/auth/compass/user/util/user-metadata.util.test.ts +++ b/packages/web/src/auth/compass/user/util/user-metadata.util.test.ts @@ -1,6 +1,6 @@ import { type UserMetadata } from "@core/types/user.types"; import { createTestToastPort } from "@web/__tests__/helpers/web-test-seams"; -import { UserApi } from "@web/api/user.api"; +import { BaseApi } from "@web/api/base/base.api"; import { resetGoogleReconnectRequiredForTests } from "@web/auth/google/state/google.reconnect.state"; import { GOOGLE_DELAYED_TOAST_ID } from "@web/common/constants/toast.constants"; import { registerToastPort } from "@web/common/utils/toast/toast.port"; @@ -71,22 +71,33 @@ describe("applyUserMetadataSideEffects - delayed toast lifecycle", () => { describe("refreshUserMetadata force coalescing", () => { it("chains concurrent force calls onto one trailing fetch", async () => { - let resolveFirst!: (value: UserMetadata) => void; - const first = new Promise((resolve) => { + // Spy BaseApi.get, not UserApi.getMetadata: other files' mock.module of + // UserApi can leave this file spying a different object than the util + // closed over at first load. + const get = spyOn(BaseApi, "get").mockResolvedValue({ + data: healthy, + } as never); + await refreshUserMetadata({ force: true }); + get.mockReset(); + + let resolveFirst!: (value: { data: UserMetadata }) => void; + const first = new Promise<{ data: UserMetadata }>((resolve) => { resolveFirst = resolve; }); - const getMetadata = spyOn(UserApi, "getMetadata") - .mockImplementationOnce(() => first) - .mockResolvedValue(healthy); + get + .mockImplementationOnce(() => first as never) + .mockResolvedValue({ + data: healthy, + } as never); const inFlight = refreshUserMetadata(); const forceA = refreshUserMetadata({ force: true }); const forceB = refreshUserMetadata({ force: true }); - resolveFirst(attention); + resolveFirst({ data: attention }); await Promise.all([inFlight, forceA, forceB]); - expect(getMetadata).toHaveBeenCalledTimes(2); - getMetadata.mockRestore(); + expect(get).toHaveBeenCalledTimes(2); + get.mockRestore(); }); }); diff --git a/packages/web/src/sse/hooks/useTransientSyncPolling.test.ts b/packages/web/src/sse/hooks/useTransientSyncPolling.test.ts index aa7595d139..dbf0935039 100644 --- a/packages/web/src/sse/hooks/useTransientSyncPolling.test.ts +++ b/packages/web/src/sse/hooks/useTransientSyncPolling.test.ts @@ -30,14 +30,14 @@ describe("useTransientSyncPolling", () => { beforeEach(() => { intervalCallback = undefined; mockRefreshUserMetadata.mockClear(); - setIntervalSpy = spyOn(globalThis, "setInterval").mockImplementation((( + setIntervalSpy = spyOn(window, "setInterval").mockImplementation((( callback: TimerHandler, ) => { if (typeof callback === "function") { intervalCallback = () => callback(); } - return 1 as unknown as ReturnType; - }) as unknown as typeof setInterval); + return 1; + }) as typeof window.setInterval); clearIntervalSpy = spyOn(globalThis, "clearInterval").mockImplementation( () => {}, ); From 1542c654a1981f24b6202d82527b449327d9d39b Mon Sep 17 00:00:00 2001 From: Cursor Agent Date: Thu, 13 Aug 2026 00:19:46 +0000 Subject: [PATCH 5/7] test(web): spy refreshUserMetadata without a process-wide module mock mock.module leaked into later files and hid BaseApi.get. Use a restored spy and the same global timers as useMinuteTick so import polling stops when the connection settles. Co-authored-by: Tyler Dane --- .../sse/hooks/useTransientSyncPolling.test.ts | 44 ++++++++----------- .../src/sse/hooks/useTransientSyncPolling.ts | 4 +- 2 files changed, 21 insertions(+), 27 deletions(-) diff --git a/packages/web/src/sse/hooks/useTransientSyncPolling.test.ts b/packages/web/src/sse/hooks/useTransientSyncPolling.test.ts index dbf0935039..bc3a25bf8e 100644 --- a/packages/web/src/sse/hooks/useTransientSyncPolling.test.ts +++ b/packages/web/src/sse/hooks/useTransientSyncPolling.test.ts @@ -1,24 +1,9 @@ import { renderHook } from "@testing-library/react"; import { act } from "react"; +import * as userMetadataUtil from "@web/auth/compass/user/util/user-metadata.util"; import { userMetadataActions } from "@web/auth/state/user-metadata.store"; -import { - afterEach, - beforeEach, - describe, - expect, - it, - mock, - spyOn, -} from "bun:test"; - -const mockRefreshUserMetadata = mock().mockResolvedValue(undefined); - -mock.module("@web/auth/compass/user/util/user-metadata.util", () => ({ - refreshUserMetadata: mockRefreshUserMetadata, -})); - -const { useTransientSyncPolling } = - require("./useTransientSyncPolling") as typeof import("./useTransientSyncPolling"); +import { useTransientSyncPolling } from "./useTransientSyncPolling"; +import { afterEach, beforeEach, describe, expect, it, spyOn } from "bun:test"; const TRANSIENT_POLL_MS = 20_000; @@ -26,27 +11,34 @@ describe("useTransientSyncPolling", () => { let intervalCallback: (() => void) | undefined; let setIntervalSpy: ReturnType; let clearIntervalSpy: ReturnType; + let refreshSpy: ReturnType; beforeEach(() => { intervalCallback = undefined; - mockRefreshUserMetadata.mockClear(); - setIntervalSpy = spyOn(window, "setInterval").mockImplementation((( + refreshSpy = spyOn( + userMetadataUtil, + "refreshUserMetadata", + ).mockResolvedValue(undefined); + setIntervalSpy = spyOn(globalThis, "setInterval").mockImplementation((( callback: TimerHandler, ) => { if (typeof callback === "function") { intervalCallback = () => callback(); } - return 1; - }) as typeof window.setInterval); + return 1 as unknown as ReturnType; + }) as unknown as typeof setInterval); clearIntervalSpy = spyOn(globalThis, "clearInterval").mockImplementation( () => {}, ); }); afterEach(() => { + refreshSpy.mockRestore(); setIntervalSpy.mockRestore(); clearIntervalSpy.mockRestore(); - userMetadataActions.clear(); + act(() => { + userMetadataActions.clear(); + }); }); it("polls metadata while a connection is importing and stops when it settles", () => { @@ -77,7 +69,7 @@ describe("useTransientSyncPolling", () => { act(() => { intervalCallback?.(); }); - expect(mockRefreshUserMetadata).toHaveBeenCalledWith({ force: true }); + expect(refreshSpy).toHaveBeenCalledWith({ force: true }); act(() => { userMetadataActions.set({ @@ -100,6 +92,7 @@ describe("useTransientSyncPolling", () => { hook.rerender(); expect(clearIntervalSpy).toHaveBeenCalled(); + hook.unmount(); }); it("does not poll when no connection is transient", () => { @@ -120,8 +113,9 @@ describe("useTransientSyncPolling", () => { }, }); - renderHook(() => useTransientSyncPolling()); + const { unmount } = renderHook(() => useTransientSyncPolling()); expect(setIntervalSpy).not.toHaveBeenCalled(); + unmount(); }); }); diff --git a/packages/web/src/sse/hooks/useTransientSyncPolling.ts b/packages/web/src/sse/hooks/useTransientSyncPolling.ts index a4dcca1395..10b2606155 100644 --- a/packages/web/src/sse/hooks/useTransientSyncPolling.ts +++ b/packages/web/src/sse/hooks/useTransientSyncPolling.ts @@ -26,9 +26,9 @@ export const useTransientSyncPolling = () => { useEffect(() => { if (!anyTransient) return; - const id = window.setInterval(() => { + const id = setInterval(() => { void refreshUserMetadata({ force: true }); }, TRANSIENT_POLL_MS); - return () => window.clearInterval(id); + return () => clearInterval(id); }, [anyTransient]); }; From 8b1aab27c806eaa955a12ceccf82f0dee5fa3889 Mon Sep 17 00:00:00 2001 From: Cursor Agent Date: Thu, 13 Aug 2026 00:22:35 +0000 Subject: [PATCH 6/7] test(web): stop SessionProvider from replacing metadata refresh globally mock.module turned refreshUserMetadata into a no-op for later files, and an unrestored BaseApi.get spy hid CalendarList's adapter error path. Co-authored-by: Tyler Dane --- .../compass/session/SessionProvider.test.tsx | 15 +++++++++------ .../user/util/user-metadata.util.test.ts | 19 +++++++++++-------- 2 files changed, 20 insertions(+), 14 deletions(-) diff --git a/packages/web/src/auth/compass/session/SessionProvider.test.tsx b/packages/web/src/auth/compass/session/SessionProvider.test.tsx index 9ee152afa8..7df2b1b49f 100644 --- a/packages/web/src/auth/compass/session/SessionProvider.test.tsx +++ b/packages/web/src/auth/compass/session/SessionProvider.test.tsx @@ -2,6 +2,7 @@ import { renderHook, waitFor } from "@testing-library/react"; import { act, useContext } from "react"; import { session } from "@web/auth/compass/session/Session"; import { DEFAULT_AUTH_STATE } from "@web/auth/compass/state/auth.state.util"; +import * as userMetadataUtil from "@web/auth/compass/user/util/user-metadata.util"; import { initialUserMetadataState, userMetadataActions, @@ -18,8 +19,13 @@ import { spyOn, } from "bun:test"; -// Create mocks at module level -const refreshUserMetadata = mock().mockResolvedValue(undefined); +// spyOn (restorable) rather than mock.module: mock.module leaks process-wide +// and would replace refreshUserMetadata with a no-op for later files that +// exercise the real coalescing/fetch path. +const refreshUserMetadata = spyOn( + userMetadataUtil, + "refreshUserMetadata", +).mockResolvedValue(undefined); // SSEProvider.test.tsx has its own dedicated test importing the real module — // mock.module leaks process-wide across files, so spy on the real module's // exports (restorable) instead of mock.module'ing the whole path. @@ -44,10 +50,6 @@ const subscribeToAuthState = mock(); const updateAuthState = mock(); const doesSessionExist = spyOn(session, "doesSessionExist"); -mock.module("@web/auth/compass/user/util/user-metadata.util", () => ({ - refreshUserMetadata, -})); - mock.module("@web/auth/compass/state/auth.state.util", () => ({ clearAnonymousCalendarChangeSignUpPrompt, clearAuthenticationState, @@ -68,6 +70,7 @@ const { SessionProvider, sessionInit } = describe("SessionProvider sessionInit", () => { afterAll(() => { + refreshUserMetadata.mockRestore(); openStream.mockRestore(); closeStream.mockRestore(); getStream.mockRestore(); diff --git a/packages/web/src/auth/compass/user/util/user-metadata.util.test.ts b/packages/web/src/auth/compass/user/util/user-metadata.util.test.ts index 3866f47a81..8fb32d8c0e 100644 --- a/packages/web/src/auth/compass/user/util/user-metadata.util.test.ts +++ b/packages/web/src/auth/compass/user/util/user-metadata.util.test.ts @@ -8,7 +8,7 @@ import { applyUserMetadataSideEffects, refreshUserMetadata, } from "./user-metadata.util"; -import { beforeEach, describe, expect, it, spyOn } from "bun:test"; +import { afterEach, beforeEach, describe, expect, it, spyOn } from "bun:test"; const healthy: UserMetadata = { google: { connectionState: "HEALTHY" } }; const attention: UserMetadata = { google: { connectionState: "ATTENTION" } }; @@ -70,15 +70,19 @@ describe("applyUserMetadataSideEffects - delayed toast lifecycle", () => { }); describe("refreshUserMetadata force coalescing", () => { + let get: ReturnType | undefined; + + afterEach(() => { + get?.mockRestore(); + get = undefined; + }); + it("chains concurrent force calls onto one trailing fetch", async () => { // Spy BaseApi.get, not UserApi.getMetadata: other files' mock.module of // UserApi can leave this file spying a different object than the util - // closed over at first load. - const get = spyOn(BaseApi, "get").mockResolvedValue({ - data: healthy, - } as never); - await refreshUserMetadata({ force: true }); - get.mockReset(); + // closed over at first load. Always restore in afterEach — a leaked spy + // would swallow CalendarList's adapter-based error path. + get = spyOn(BaseApi, "get"); let resolveFirst!: (value: { data: UserMetadata }) => void; const first = new Promise<{ data: UserMetadata }>((resolve) => { @@ -98,6 +102,5 @@ describe("refreshUserMetadata force coalescing", () => { await Promise.all([inFlight, forceA, forceB]); expect(get).toHaveBeenCalledTimes(2); - get.mockRestore(); }); }); From 7e4593110ca9c9d04b46bdec3beb7f522a9d772e Mon Sep 17 00:00:00 2001 From: Cursor Agent Date: Thu, 13 Aug 2026 00:49:54 +0000 Subject: [PATCH 7/7] fix(web): keep day create off the local calendar after google connect Restoring the local Day column made click-to-create land on Compass. Gate that surface with getWritableCalendars so LCV1/LCV2 still hold. Co-authored-by: Tyler Dane --- .../Calendar/DayCalendarGrid.test.tsx | 41 +++++++++++++++++++ .../components/Calendar/DayCalendarGrid.tsx | 26 ++++++++++-- 2 files changed, 63 insertions(+), 4 deletions(-) diff --git a/packages/web/src/views/Day/components/Calendar/DayCalendarGrid.test.tsx b/packages/web/src/views/Day/components/Calendar/DayCalendarGrid.test.tsx index 3937011bf2..90366c03a4 100644 --- a/packages/web/src/views/Day/components/Calendar/DayCalendarGrid.test.tsx +++ b/packages/web/src/views/Day/components/Calendar/DayCalendarGrid.test.tsx @@ -15,8 +15,10 @@ import { within, } from "@web/__tests__/__mocks__/mock.render"; import { server } from "@web/__tests__/__mocks__/server/mock.server"; +import { createMockConnection } from "@web/__tests__/utils/factories/calendar.factory"; import { createMockEvent } from "@web/__tests__/utils/factories/event.factory"; import { createCompassQueryClient } from "@web/api/query-client"; +import { userMetadataActions } from "@web/auth/state/user-metadata.store"; import { calendarQueryKeys } from "@web/calendars/calendar.query"; import { setCalendarVisibility } from "@web/calendars/calendar-visibility.store"; import { getLocalCalendarSentinelId } from "@web/calendars/local-calendar.sentinel"; @@ -951,6 +953,45 @@ describe("DayCalendarGrid", () => { ); }); + it("does not create on the local column once a Google account is connected", async () => { + const local = makeCalendar("Compass", { + id: getLocalCalendarSentinelId(), + provider: "local", + }); + const google = makeCalendar("Primary", { isPrimary: true }); + userMetadataActions.set({ + google: { + connectionState: "HEALTHY", + connections: [createMockConnection("ahab@pequod.com")], + }, + }); + const { user } = renderDayCalendarGrid([local, google]); + + await user.pointer([ + { + coords: { clientX: 90, clientY: 120 }, + keys: "[MouseLeft>]", + target: getTimedSlot(3), + }, + { + coords: { clientX: 90, clientY: 120 }, + keys: "[/MouseLeft]", + target: getTimedSlot(3), + }, + ]); + + expect(getDraft()).toBeNull(); + expect(screen.queryByRole("dialog", { name: "Event form" })).toBeNull(); + + const showError = mock(); + expect( + canCreateDraftOnCalendar(local, showError, new Set([google.id])), + ).toBeFalse(); + expect(showError).toHaveBeenCalledWith( + "You can't edit the Compass calendar.", + ); + }); + it("renders a saved all-day event at full calendar-column width", async () => { const primary = makeCalendar("Primary", { isPrimary: true }); const projects = makeCalendar("Projects"); diff --git a/packages/web/src/views/Day/components/Calendar/DayCalendarGrid.tsx b/packages/web/src/views/Day/components/Calendar/DayCalendarGrid.tsx index c042ab7b26..c965e40c11 100644 --- a/packages/web/src/views/Day/components/Calendar/DayCalendarGrid.tsx +++ b/packages/web/src/views/Day/components/Calendar/DayCalendarGrid.tsx @@ -12,7 +12,11 @@ import { shouldShowContextualLoadError } from "@web/api/util/api.util"; import { useConnectGoogle } from "@web/auth/google/hooks/useConnectGoogle/useConnectGoogle"; import { isFirstImportInProgress } from "@web/auth/google/hooks/useConnectGoogle/useConnectGoogle.util"; import { useCalendarsQuery } from "@web/calendars/calendar.query"; -import { useDefaultTargetCalendar } from "@web/calendars/useDefaultTargetCalendar"; +import { getWritableCalendars } from "@web/calendars/calendar.util"; +import { + useConnectedAccountEmails, + useDefaultTargetCalendar, +} from "@web/calendars/useDefaultTargetCalendar"; import { type GridEvent } from "@web/common/types/web.event.types"; import { onViewCommand } from "@web/common/utils/dom/view-command-bus"; import { @@ -62,8 +66,13 @@ import { useDayTimedDraftCreation } from "./useDayTimedDraftCreation"; export const canCreateDraftOnCalendar = ( calendar: Calendar | null, showError: (message: string) => unknown = showErrorToast, + writableCalendarIds?: ReadonlySet, ): boolean => { - if (!calendar || calendar.capabilities.canWrite) return true; + if (!calendar) return true; + const canWrite = writableCalendarIds + ? writableCalendarIds.has(calendar.id) + : calendar.capabilities.canWrite; + if (canWrite) return true; showError(`You can't edit the ${calendar.name} calendar.`); return false; @@ -80,6 +89,13 @@ export function DayCalendarGrid() { // Seed shortcuts with the form's default create target, not day-column order. const defaultTargetCalendarId = useDefaultTargetCalendar(calendars)?.id ?? null; + const accountEmailOrder = useConnectedAccountEmails(); + const writableCalendarIds = useMemo(() => { + const writable = getWritableCalendars(calendars, { + hasConnectedAccount: accountEmailOrder.length > 0, + }); + return new Set(writable.map((calendar) => calendar.id)); + }, [accountEmailOrder.length, calendars]); const { allDayEvents, error: eventsError, @@ -336,7 +352,9 @@ export function DayCalendarGrid() { ) => { const calendar = getCalendarAtX(event.clientX); - if (!canCreateDraftOnCalendar(calendar)) { + if ( + !canCreateDraftOnCalendar(calendar, showErrorToast, writableCalendarIds) + ) { event.preventDefault(); event.stopPropagation(); return; @@ -344,7 +362,7 @@ export function DayCalendarGrid() { createDraft(event, calendar?.id ?? null); }, - [getCalendarAtX], + [getCalendarAtX, writableCalendarIds], ); const handleAllDayMouseDown = useCallback( (event: ReactMouseEvent) => {