diff --git a/apps/sim/connectors/google-workspace/company-crawl.test.ts b/apps/sim/connectors/google-workspace/company-crawl.test.ts index cc4bfd3b715..40125bce020 100644 --- a/apps/sim/connectors/google-workspace/company-crawl.test.ts +++ b/apps/sim/connectors/google-workspace/company-crawl.test.ts @@ -502,21 +502,24 @@ describe('Google Workspace per-user central crawl', () => { expect((await list(context(), undefined, CONFIG, 'google_calendar')).documents).toHaveLength(1) }) - it('isolates explicit Calendar list access failures without claiming a disabled service', async () => { - listUserDocuments.mockRejectedValueOnce( - new GoogleApiError('calendar.events.list', 403, ['forbidden']) - ) - const first = await list(context(), undefined, CONFIG, 'google_calendar') - expect(first.listingFailures?.samples[0]).toEqual({ - scope: 'alice@corp.com', - operation: 'calendar.events.list', - status: 403, - reasons: ['forbidden'], - }) - const second = await list(context(), first.nextCursor, CONFIG, 'google_calendar') - expect(second.documents[0].acl).toEqual(['u:bob@corp.com']) - expect(second.reconciliationSafe).toBe(false) - }) + it.each(['forbidden', 'notACalendarUser'])( + 'isolates explicit Calendar list access failures (%s) without claiming a disabled service', + async (reason) => { + listUserDocuments.mockRejectedValueOnce( + new GoogleApiError('calendar.events.list', 403, [reason]) + ) + const first = await list(context(), undefined, CONFIG, 'google_calendar') + expect(first.listingFailures?.samples[0]).toEqual({ + scope: 'alice@corp.com', + operation: 'calendar.events.list', + status: 403, + reasons: [reason], + }) + const second = await list(context(), first.nextCursor, CONFIG, 'google_calendar') + expect(second.documents[0].acl).toEqual(['u:bob@corp.com']) + expect(second.reconciliationSafe).toBe(false) + } + ) it.each([{ error: { code: 403 } }, { error: { code: 403, errors: [], details: [] } }])( 'propagates a Calendar 403 without reason codes: %j', @@ -542,6 +545,9 @@ describe('Google Workspace per-user central crawl', () => { [403, ['domainPolicy']], [403, ['unrecognized-provider-code']], [403, ['forbidden', 'unrecognized-provider-code']], + [403, ['notACalendarUser', 'unrecognized-provider-code']], + [403, ['notACalendarUser', 'insufficientPermissions']], + [403, ['notACalendarUser', 'rateLimitExceeded']], [401, ['authError']], [429, []], [500, ['backendError']], @@ -571,6 +577,15 @@ describe('Google Workspace per-user central crawl', () => { await expect(list(context(), undefined, CONFIG, 'google_calendar')).rejects.toBe(error) }) + it.each([ + new GoogleApiError('calendar.events.list', 403, ['notACalendarUser'], false), + new GoogleApiError('calendar.calendarList.list', 403, ['notACalendarUser']), + new GoogleApiError('calendar.events.list', 401, ['notACalendarUser']), + ])('does not isolate Calendar unavailability outside a complete list 403: %s', async (error) => { + listUserDocuments.mockRejectedValueOnce(error) + await expect(list(context(), undefined, CONFIG, 'google_calendar')).rejects.toBe(error) + }) + it('does not suppress delegation failures that resemble provider list failures', async () => { const ctx = context() const error = new GoogleApiError('gmail.threads.list', 400, ['failedPrecondition']) diff --git a/apps/sim/connectors/google-workspace/company-crawl.ts b/apps/sim/connectors/google-workspace/company-crawl.ts index 6202173cee9..241828d10ee 100644 --- a/apps/sim/connectors/google-workspace/company-crawl.ts +++ b/apps/sim/connectors/google-workspace/company-crawl.ts @@ -177,7 +177,7 @@ function userListingFailure( : error.diagnostic.operation === 'calendar.events.list' && error.status === 403 && reasons.length > 0 && - reasons.every((reason) => reason === 'forbidden') + reasons.every((reason) => reason === 'forbidden' || reason === 'notACalendarUser') return isolated ? { operation: error.diagnostic.operation, status: error.status, reasons: [...reasons] } : null diff --git a/apps/sim/lib/knowledge/connectors/google-company-scheduler.test.ts b/apps/sim/lib/knowledge/connectors/google-company-scheduler.test.ts index 9b06238dfac..822beb3c60d 100644 --- a/apps/sim/lib/knowledge/connectors/google-company-scheduler.test.ts +++ b/apps/sim/lib/knowledge/connectors/google-company-scheduler.test.ts @@ -15,15 +15,18 @@ import type { } from '@/lib/knowledge/connectors/partition-work' import { GoogleDriveApiError } from '@/connectors/google-drive/google-drive-errors' import { GoogleApiError } from '@/connectors/google-workspace/api-errors' +import { listGoogleWorkspaceDocuments } from '@/connectors/google-workspace/company-crawl' import { googleCompanyUserContextSchema } from '@/connectors/google-workspace/company-work' import type { GoogleWorkspaceUser } from '@/connectors/google-workspace/users' import { ConnectorSourceError } from '@/connectors/source-error' import type { ConnectorConfig, ExternalDocument, ExternalListingFailures } from '@/connectors/types' +import { memberDocumentId } from '@/connectors/utils' -const mocks = vi.hoisted(() => ({ directory: vi.fn() })) +const mocks = vi.hoisted(() => ({ directory: vi.fn(), getUser: vi.fn() })) vi.mock('@/connectors/google-workspace/users', async (original) => ({ ...(await original()), listGoogleWorkspaceUsers: mocks.directory, + getGoogleWorkspaceUser: mocks.getUser, })) const document: ExternalDocument = { @@ -203,6 +206,7 @@ function fixture(provider = 'google_calendar', syncIntervalMinutes = 60) { beforeEach(() => { mocks.directory.mockReset() + mocks.getUser.mockReset().mockImplementation(async (_token: string, id: string) => user(id)) }) describe('durable Google company user scheduling', () => { @@ -269,6 +273,58 @@ describe('durable Google company user scheduling', () => { } ) + it('continues past unavailable Calendar users without the unresolved-error pause and retries them later', async () => { + mocks.directory.mockResolvedValue({ users: ['a', 'b', 'c', 'z'].map(user) }) + const f = fixture() + const listUserDocuments = vi.fn( + async (_token, _config, _cursor, ctx) => ({ + documents: [{ ...document, externalId: memberDocumentId('event', ctx) }], + hasMore: false, + }) + ) + for (let i = 0; i < 3; i++) { + listUserDocuments.mockRejectedValueOnce( + new GoogleApiError('calendar.events.list', 403, ['notACalendarUser']) + ) + } + const syncContext = { + mirrorsSourceAcls: true, + getDelegatedAccessToken: vi.fn(async () => 'user-token'), + } + f.list.mockImplementation(async (accessToken, sourceConfig, cursor) => + listGoogleWorkspaceDocuments({ + provider: 'google_calendar', + accessToken, + sourceConfig, + cursor, + syncContext, + listUserDocuments, + }) + ) + + await f.step(5) + + expect(f.rows.get('z:content')?.complete).toBe(true) + expect(f.saved()).toMatchObject({ complete: false, unsafe: true, resumeAt: null }) + for (const id of ['a', 'b', 'c']) { + expect(f.rows.get(`${id}:content`)).toMatchObject({ + complete: false, + attempts: 1, + retryAt: new Date('2026-09-17T01:00:00Z'), + failure: { status: 403, reasons: ['notACalendarUser'] }, + }) + } + + f.advance(60 * 60 * 1000) + f.restart() + await f.step(4) + + for (const id of ['a', 'b', 'c']) { + expect(f.rows.get(`${id}:content`)).toMatchObject({ complete: true, attempts: 0 }) + expect(f.rows.get(`${id}:content`)?.failure).toBeUndefined() + } + }) + it('bounds a run of unresolved user errors rather than marking the tenant complete', async () => { mocks.directory.mockResolvedValue({ users: ['a', 'b', 'c', 'd'].map(user) }) const f = fixture()