snapshot of https://github.com/twentyhq/twenty.git (main, 2026-09-17; blobs > 131072B omitted)
Some checks failed
App Prod-Parity E2E Dispatch / dispatch (push) Failing after 10s
CD Storybook UI / build (push) Failing after 3m25s
CD Storybook UI / dispatch (push) Has been skipped
CD deploy main / website-changed-files (push) Successful in 18m8s
CI Agent Skills / changed-files-check (push) Successful in 18m9s
CI Create App E2E minimal / changed-files-check (push) Successful in 24s
CI Create App / changed-files-check (push) Successful in 26s
CI Docs / changed-files-check (push) Successful in 26s
CI Emails / changed-files-check (push) Successful in 28s
CI Example App Hello World / changed-files-check (push) Successful in 23s
CI Front / changed-files-check (push) Has been skipped
CI Example App Postcard / changed-files-check (push) Successful in 27s
CI UI / changed-files-check (push) Has been skipped
CI Utils Test / changed-files-check (push) Successful in 45s
CI Twenty Apps / discover (push) Successful in 1m3s
CD deploy main / deploy-main (push) Failing after 3s
CI Agent Skills / agent-skills-validate (push) Has been skipped
CI Create App / create-app-test (lint) (push) Has been skipped
CI Emails / emails-test (push) Has been skipped
CI Front / front-task (lingui:compile) (push) Has been skipped
CI Front / front-task (lint) (push) Has been skipped
CI Example App Postcard / example-app-postcard (push) Has been skipped
CI UI / ui-task (lint) (push) Has been skipped
CI UI / ui-task (pack-size) (push) Has been skipped
CI UI / ui-task (size) (push) Has been skipped
CI UI / ui-task (test) (push) Has been skipped
CI UI / ui-task (typecheck) (push) Has been skipped
CI Utils Test / utils-test (push) Has been skipped
CI Twenty Apps / ${{ matrix.app.name }} (push) Has been skipped
CI Emails / ci-emails-status-check (push) Successful in 2s
CI Example App Hello World / ci-example-app-hello-world-status-check (push) Successful in 2s
CI Example App Postcard / ci-example-app-postcard-status-check (push) Successful in 3s
CI UI / ui-sb-test (push) Has been skipped
CI Utils Test / ci-utils-test-status-check (push) Successful in 2s
CI Twenty Apps / ci-twenty-apps-status-check (push) Successful in 2s
CI UI / ci-ui-status-check (push) Successful in 2s
CI Desktop / desktop (push) Failing after 1m8s
Push docs to Crowdin / Push documentation to Crowdin (push) Failing after 55s
Push translations to Crowdin / Extract and upload translations (push) Failing after 5s
Push website translations to Crowdin / Extract and upload website translations (push) Failing after 5s
CI Create App / create-app-test (test) (push) Has been skipped
CI Create App / create-app-test (typecheck) (push) Has been skipped
CI Docs / docs-lint (push) Has been skipped
CI Example App Hello World / example-app-hello-world (push) Has been skipped
CI Front / front-task (lingui:extract) (push) Has been skipped
CI Front / front-task (test) (push) Has been skipped
CI Front / front-task (typecheck) (push) Has been skipped
CI UI / ui-sb-build (push) Failing after 1m32s
CI Twenty Apps / ${{ matrix.app.name }} (${{ matrix.server-source }}) (push) Has been skipped
CI Agent Skills / agent-skills-publish (push) Has been skipped
CI Create App / ci-create-app-status-check (push) Successful in 2s
CI Agent Skills / agent-skills-remote-source (push) Has been skipped
CI Create App E2E minimal / create-app-e2e-minimal (push) Has been cancelled
CI E2E Main / e2e-test (push) Has been cancelled
CI Front / front-sb-build (push) Has been cancelled
CI Front / front-build (push) Has been cancelled
CI Create App E2E minimal / ci-create-app-e2e-minimal-status-check (push) Has been cancelled
CI E2E Main / ci-e2e-main-status-check (push) Has been cancelled
CI E2E Main / notify-main-ci-failure (push) Has been cancelled
CI E2E Main / qa-scout-comment (push) Has been cancelled
CI Front / front-sb-test (push) Has been cancelled
CI Front / front-sb-screenshots (push) Has been cancelled
CI Front / ci-front-status-check (push) Has been cancelled
DPA Sub-Processor Sync / sync-subprocessors (push) Failing after 3s
AI Catalog Sync / sync-catalog (push) Failing after 2s

This commit is contained in:
upstream-mirror
2026-09-17 23:28:13 +05:00
commit e7da97194a
32688 changed files with 4208681 additions and 0 deletions

View File

@@ -0,0 +1,108 @@
import { randomUUID } from 'node:crypto';
import {
CalendarChannelVisibility,
ConnectedAccountProvider,
} from 'twenty-shared/types';
import { FIELD_RESTRICTED_ADDITIONAL_PERMISSIONS_REQUIRED } from 'twenty-shared/constants';
import { CalendarChannelEntity } from 'src/engine/metadata-modules/calendar-channel/entities/calendar-channel.entity';
import { findManyOperationFactory } from 'test/integration/graphql/utils/find-many-operation-factory.util';
import { makeGraphqlAPIRequest } from 'test/integration/graphql/utils/make-graphql-api-request.util';
import { makeGraphqlAPIRequestWithMemberRole } from 'test/integration/graphql/utils/make-graphql-api-request-with-member-role.util';
import { googleCalendarEvent } from 'test/integration/google/mocks/google-calendar-event.util';
import { setupGoogleMock } from 'test/integration/google/mocks/setup-google-mock.util';
import { connectMessagingAccount } from 'test/integration/utils/connect-messaging-account.util';
import { getCoreRepository } from 'test/integration/utils/get-core-repository.util';
import { runCalendarChannelEventsImport } from 'test/integration/utils/run-calendar-channel-events-import.util';
import { runCalendarChannelListFetch } from 'test/integration/utils/run-calendar-channel-list-fetch.util';
const HANDLE = 'google-calendar-visibility@apple.dev';
const RESTRICTED = FIELD_RESTRICTED_ADDITIONAL_PERMISSIONS_REQUIRED;
describe('Calendar channel visibility (integration)', () => {
const eventTitle = `Calendar event ${randomUUID()}`;
const gmail = setupGoogleMock({ handle: HANDLE });
let channel: Awaited<ReturnType<typeof connectMessagingAccount>>;
const calendarEventQuery = () =>
findManyOperationFactory({
objectMetadataSingularName: 'calendarEvent',
objectMetadataPluralName: 'calendarEvents',
gqlFields: 'title description',
filter: { title: { eq: eventTitle } },
});
const readEventAs = async (
makeRequest:
| typeof makeGraphqlAPIRequest
| typeof makeGraphqlAPIRequestWithMemberRole,
) => {
const response = await makeRequest(calendarEventQuery());
expect(response.body.errors).toBeUndefined();
return response.body.data.calendarEvents.edges.map(
(edge: { node: { title: string; description: string } }) => edge.node,
);
};
const setVisibility = async (visibility: CalendarChannelVisibility) => {
await getCoreRepository<CalendarChannelEntity>(
CalendarChannelEntity,
).update({ id: channel.calendarChannelId }, { visibility });
};
beforeAll(async () => {
channel = await connectMessagingAccount({
provider: ConnectedAccountProvider.GOOGLE,
handle: HANDLE,
});
gmail.serveCalendarEvents([
googleCalendarEvent({
summary: eventTitle,
description: 'Agenda for the meeting',
}),
]);
await runCalendarChannelListFetch(channel.calendarChannelId);
await runCalendarChannelEventsImport(channel.calendarChannelId);
}, 120000);
afterAll(async () => {
await channel?.cleanup().catch(() => undefined);
});
it('shows the full event to another member when the channel shares everything', async () => {
await setVisibility(CalendarChannelVisibility.SHARE_EVERYTHING);
const [event] = await readEventAs(makeGraphqlAPIRequestWithMemberRole);
expect(event.title).toBe(eventTitle);
expect(event.description).not.toBe(RESTRICTED);
}, 60000);
it('masks the title and description for another member under metadata visibility', async () => {
await setVisibility(CalendarChannelVisibility.METADATA);
const [event] = await readEventAs(makeGraphqlAPIRequestWithMemberRole);
expect(event.title).toBe(RESTRICTED);
expect(event.description).toBe(RESTRICTED);
}, 60000);
it('always shows the full event to the owner of the connected account', async () => {
await setVisibility(CalendarChannelVisibility.METADATA);
const [event] = await readEventAs(makeGraphqlAPIRequest);
expect(event.title).toBe(eventTitle);
expect(event.description).not.toBe(RESTRICTED);
}, 60000);
});

View File

@@ -0,0 +1,102 @@
import { randomUUID } from 'node:crypto';
import { ConnectedAccountProvider } from 'twenty-shared/types';
import { googleCalendarEvent } from 'test/integration/google/mocks/google-calendar-event.util';
import { setupGoogleMock } from 'test/integration/google/mocks/setup-google-mock.util';
import { connectMessagingAccount } from 'test/integration/utils/connect-messaging-account.util';
import {
findRecordIdsByFilter,
findRecordNodesByFilter,
} from 'test/integration/utils/find-records-by-filter.util';
import { deleteConnectedAccount } from 'test/integration/utils/query-messaging.util';
import { runCalendarChannelEventsImport } from 'test/integration/utils/run-calendar-channel-events-import.util';
import { runCalendarChannelListFetch } from 'test/integration/utils/run-calendar-channel-list-fetch.util';
const HANDLE = 'calendar-cleanup@apple.dev';
describe('Calendar connected account cleanup (integration)', () => {
const eventId = `google-calendar-event-${randomUUID()}`;
const gmail = setupGoogleMock({ handle: HANDLE });
let channel: Awaited<ReturnType<typeof connectMessagingAccount>>;
beforeAll(async () => {
channel = await connectMessagingAccount({
provider: ConnectedAccountProvider.GOOGLE,
handle: HANDLE,
});
gmail.serveCalendarEvents([
googleCalendarEvent({
id: eventId,
attendees: [
{ email: `organizer-${eventId}@example.com`, organizer: true },
{ email: `attendee-${eventId}@example.com` },
],
}),
]);
await runCalendarChannelListFetch(channel.calendarChannelId);
await runCalendarChannelEventsImport(channel.calendarChannelId);
}, 60000);
afterAll(async () => {
await channel?.cleanup().catch(() => undefined);
});
it('deletes all associated calendar data when the connected account is removed', async () => {
const associations = await findRecordNodesByFilter<{
id: string;
calendarEventId: string;
}>(
'calendarChannelEventAssociation',
'calendarChannelEventAssociations',
`id
calendarEventId`,
{ calendarChannelId: { eq: channel.calendarChannelId } },
);
expect(associations).toHaveLength(1);
const eventIds = associations.map(
(association) => association.calendarEventId,
);
expect(
await findRecordIdsByFilter('calendarEvent', 'calendarEvents', {
id: { in: eventIds },
}),
).not.toHaveLength(0);
expect(
await findRecordIdsByFilter(
'calendarEventParticipant',
'calendarEventParticipants',
{ calendarEventId: { in: eventIds } },
),
).not.toHaveLength(0);
await deleteConnectedAccount(channel.connectedAccountId);
expect(
await findRecordIdsByFilter(
'calendarChannelEventAssociation',
'calendarChannelEventAssociations',
{ calendarChannelId: { eq: channel.calendarChannelId } },
),
).toHaveLength(0);
expect(
await findRecordIdsByFilter('calendarEvent', 'calendarEvents', {
id: { in: eventIds },
}),
).toHaveLength(0);
expect(
await findRecordIdsByFilter(
'calendarEventParticipant',
'calendarEventParticipants',
{ calendarEventId: { in: eventIds } },
),
).toHaveLength(0);
}, 60000);
});

View File

@@ -0,0 +1,94 @@
import { randomUUID } from 'node:crypto';
import {
CalendarChannelContactAutoCreationPolicy,
ConnectedAccountProvider,
} from 'twenty-shared/types';
import { googleCalendarEvent } from 'test/integration/google/mocks/google-calendar-event.util';
import { setupGoogleMock } from 'test/integration/google/mocks/setup-google-mock.util';
import { connectMessagingAccount } from 'test/integration/utils/connect-messaging-account.util';
import { findCreatedPeopleEmails } from 'test/integration/utils/find-imported-records.util';
import {
queryCalendarChannel,
updateCalendarChannel,
} from 'test/integration/utils/query-messaging.util';
import { resetCalendarChannelSyncState } from 'test/integration/utils/reset-channel-sync-state.util';
import { runCalendarChannelEventsImport } from 'test/integration/utils/run-calendar-channel-events-import.util';
import { runCalendarChannelListFetch } from 'test/integration/utils/run-calendar-channel-list-fetch.util';
const HANDLE = 'google-calendar-contact-creation@apple.dev';
describe('Calendar contact auto-creation (integration)', () => {
const gmail = setupGoogleMock({ handle: HANDLE });
let channel: Awaited<ReturnType<typeof connectMessagingAccount>>;
const syncEventWithAttendee = async (attendee: string) => {
gmail.serveCalendarEvents(
[
googleCalendarEvent({
summary: `Calendar event ${randomUUID()}`,
attendees: [{ email: attendee }],
}),
],
{ nextSyncToken: `sync-token-${randomUUID()}` },
);
await resetCalendarChannelSyncState(channel.calendarChannelId, '');
await runCalendarChannelListFetch(channel.calendarChannelId);
await runCalendarChannelEventsImport(channel.calendarChannelId);
return findCreatedPeopleEmails([attendee]);
};
beforeAll(async () => {
channel = await connectMessagingAccount({
provider: ConnectedAccountProvider.GOOGLE,
handle: HANDLE,
});
}, 60000);
afterAll(async () => {
await channel?.cleanup().catch(() => undefined);
});
it('creates a person for an attendee when auto-creation is enabled', async () => {
await updateCalendarChannel(channel.calendarChannelId, {
isContactAutoCreationEnabled: true,
});
const attendee = `attendee-enabled-${randomUUID()}@acme.com`;
expect(await syncEventWithAttendee(attendee)).toEqual([attendee]);
}, 120000);
it('creates no person for an attendee when auto-creation is disabled', async () => {
await updateCalendarChannel(channel.calendarChannelId, {
isContactAutoCreationEnabled: false,
});
const attendee = `attendee-disabled-${randomUUID()}@acme.com`;
expect(await syncEventWithAttendee(attendee)).toEqual([]);
}, 120000);
// Calendar contact creation is gated on isContactAutoCreationEnabled alone;
// the policy is stored and served but no import path reads it.
it.each(Object.values(CalendarChannelContactAutoCreationPolicy))(
'persists the %s contact auto-creation policy',
async (contactAutoCreationPolicy) => {
await updateCalendarChannel(channel.calendarChannelId, {
contactAutoCreationPolicy,
});
const channelState = await queryCalendarChannel(channel);
expect(channelState.contactAutoCreationPolicy).toBe(
contactAutoCreationPolicy,
);
},
60000,
);
});

View File

@@ -0,0 +1,433 @@
import { randomUUID } from 'node:crypto';
import { type calendar_v3 } from 'googleapis';
import { ConnectedAccountProvider } from 'twenty-shared/types';
import { CalendarChannelEntity } from 'src/engine/metadata-modules/calendar-channel/entities/calendar-channel.entity';
import { getWorkspaceSchemaName } from 'src/engine/workspace-datasource/utils/get-workspace-schema-name.util';
import { SEED_APPLE_WORKSPACE_ID } from 'src/engine/workspace-manager/dev-seeder/core/constants/seeder-workspaces.constant';
import { createOneOperationFactory } from 'test/integration/graphql/utils/create-one-operation-factory.util';
import { makeGraphqlAPIRequest } from 'test/integration/graphql/utils/make-graphql-api-request.util';
import { googleCalendarEvent } from 'test/integration/google/mocks/google-calendar-event.util';
import { setupGoogleMock } from 'test/integration/google/mocks/setup-google-mock.util';
import { connectMessagingAccount } from 'test/integration/utils/connect-messaging-account.util';
import { findRecordNodesByFilter } from 'test/integration/utils/find-records-by-filter.util';
import { getCoreRepository } from 'test/integration/utils/get-core-repository.util';
import { resetCalendarChannelSyncState } from 'test/integration/utils/reset-channel-sync-state.util';
import { runCalendarChannelEventsImport } from 'test/integration/utils/run-calendar-channel-events-import.util';
import { runCalendarChannelListFetch } from 'test/integration/utils/run-calendar-channel-list-fetch.util';
const HANDLE = 'calendar-event-save-operations@apple.dev';
type CalendarEventNode = {
id: string;
title: string;
location: string;
isFullDay: boolean;
};
type AssociationNode = {
id: string;
eventExternalId: string;
recurringEventExternalId: string;
calendarEventId: string;
};
type ParticipantNode = {
id: string;
handle: string;
displayName: string;
isOrganizer: boolean;
responseStatus: string;
calendarEventId: string;
};
describe('Calendar event save operations (integration)', () => {
const google = setupGoogleMock({ handle: HANDLE });
let channel: Awaited<ReturnType<typeof connectMessagingAccount>>;
const importEvents = async (
events: calendar_v3.Schema$Event[],
): Promise<void> => {
google.serveCalendarEvents(events, {
nextSyncToken: `sync-token-${randomUUID()}`,
});
await resetCalendarChannelSyncState(channel.calendarChannelId, '');
await runCalendarChannelListFetch(channel.calendarChannelId);
await runCalendarChannelEventsImport(channel.calendarChannelId);
};
const importEvent = (event: calendar_v3.Schema$Event): Promise<void> =>
importEvents([event]);
const findEventsByTitle = (title: string): Promise<CalendarEventNode[]> =>
findRecordNodesByFilter<CalendarEventNode>(
'calendarEvent',
'calendarEvents',
'id title location isFullDay',
{ title: { eq: title } },
);
const findAssociationsByExternalId = (
eventExternalId: string,
): Promise<AssociationNode[]> =>
findRecordNodesByFilter<AssociationNode>(
'calendarChannelEventAssociation',
'calendarChannelEventAssociations',
'id eventExternalId recurringEventExternalId calendarEventId',
{ eventExternalId: { eq: eventExternalId } },
);
const findParticipantsByCalendarEventId = async (
calendarEventId: string,
): Promise<ParticipantNode[]> => {
const participants = await findRecordNodesByFilter<ParticipantNode>(
'calendarEventParticipant',
'calendarEventParticipants',
'id handle displayName isOrganizer responseStatus calendarEventId',
{ calendarEventId: { eq: calendarEventId } },
);
return participants.sort((a, b) => a.handle.localeCompare(b.handle));
};
const countParticipantsByHandleSuffix = async (
handleSuffix: string,
): Promise<number> => {
const rows: { count: string }[] =
await getCoreRepository<CalendarChannelEntity>(
CalendarChannelEntity,
).manager.query(
`SELECT count(*) AS count FROM "${getWorkspaceSchemaName(
SEED_APPLE_WORKSPACE_ID,
)}"."calendarEventParticipant" WHERE handle LIKE $1`,
[`%${handleSuffix}`],
);
return Number(rows[0].count);
};
const countParticipantsByHandleSuffixAndDisplayName = async (
handleSuffix: string,
displayName: string,
): Promise<number> => {
const rows: { count: string }[] =
await getCoreRepository<CalendarChannelEntity>(
CalendarChannelEntity,
).manager.query(
`SELECT count(*) AS count FROM "${getWorkspaceSchemaName(
SEED_APPLE_WORKSPACE_ID,
)}"."calendarEventParticipant" WHERE handle LIKE $1 AND "displayName" = $2`,
[`%${handleSuffix}`, displayName],
);
return Number(rows[0].count);
};
beforeAll(async () => {
channel = await connectMessagingAccount({
provider: ConnectedAccountProvider.GOOGLE,
handle: HANDLE,
});
}, 60000);
afterAll(async () => {
await channel?.cleanup().catch(() => undefined);
});
it('should insert the event, its channel association and its participants on a first import', async () => {
const eventExternalId = `google-calendar-event-${randomUUID()}`;
const title = `Calendar event ${randomUUID()}`;
const attendee = `attendee-${randomUUID()}@acme.com`;
await importEvent(
googleCalendarEvent({
id: eventExternalId,
summary: title,
location: 'Room 1',
attendees: [
{
email: attendee,
displayName: 'Ada Lovelace',
organizer: true,
responseStatus: 'accepted',
},
],
}),
);
const events = await findEventsByTitle(title);
expect(events).toHaveLength(1);
expect(events[0].location).toBe('Room 1');
expect(events[0].isFullDay).toBe(false);
const associations = await findAssociationsByExternalId(eventExternalId);
expect(associations).toHaveLength(1);
expect(associations[0].calendarEventId).toBe(events[0].id);
expect(associations[0].recurringEventExternalId).toBe('');
const participants = await findParticipantsByCalendarEventId(events[0].id);
expect(participants).toHaveLength(1);
expect(participants[0].handle).toBe(attendee);
expect(participants[0].displayName).toBe('Ada Lovelace');
expect(participants[0].isOrganizer).toBe(true);
expect(participants[0].responseStatus).toBe('ACCEPTED');
}, 120000);
it('should update the existing event and association rather than insert a second one when the same external event is imported again', async () => {
const eventExternalId = `google-calendar-event-${randomUUID()}`;
const initialTitle = `Calendar event ${randomUUID()}`;
const updatedTitle = `Calendar event ${randomUUID()}`;
const recurringEventExternalId = `recurring-${randomUUID()}`;
const attendee = `attendee-${randomUUID()}@acme.com`;
await importEvent(
googleCalendarEvent({
id: eventExternalId,
summary: initialTitle,
location: 'Room 1',
attendees: [{ email: attendee }],
}),
);
const [initialEvent] = await findEventsByTitle(initialTitle);
await importEvent(
googleCalendarEvent({
id: eventExternalId,
summary: updatedTitle,
location: 'Room 2',
recurringEventId: recurringEventExternalId,
attendees: [{ email: attendee }],
}),
);
expect(await findEventsByTitle(initialTitle)).toHaveLength(0);
const updatedEvents = await findEventsByTitle(updatedTitle);
expect(updatedEvents).toHaveLength(1);
expect(updatedEvents[0].id).toBe(initialEvent.id);
expect(updatedEvents[0].location).toBe('Room 2');
const associations = await findAssociationsByExternalId(eventExternalId);
expect(associations).toHaveLength(1);
expect(associations[0].recurringEventExternalId).toBe(
recurringEventExternalId,
);
}, 180000);
it('should update an existing participant in place when its attributes change', async () => {
const eventExternalId = `google-calendar-event-${randomUUID()}`;
const title = `Calendar event ${randomUUID()}`;
const attendee = `attendee-${randomUUID()}@acme.com`;
await importEvent(
googleCalendarEvent({
id: eventExternalId,
summary: title,
attendees: [
{
email: attendee,
displayName: 'Before',
responseStatus: 'needsAction',
},
],
}),
);
const [event] = await findEventsByTitle(title);
const [initialParticipant] = await findParticipantsByCalendarEventId(
event.id,
);
expect(initialParticipant.displayName).toBe('Before');
expect(initialParticipant.responseStatus).toBe('NEEDS_ACTION');
await importEvent(
googleCalendarEvent({
id: eventExternalId,
summary: title,
attendees: [
{
email: attendee,
displayName: 'After',
organizer: true,
responseStatus: 'declined',
},
],
}),
);
const participants = await findParticipantsByCalendarEventId(event.id);
expect(participants).toHaveLength(1);
expect(participants[0].id).toBe(initialParticipant.id);
expect(participants[0].displayName).toBe('After');
expect(participants[0].isOrganizer).toBe(true);
expect(participants[0].responseStatus).toBe('DECLINED');
}, 180000);
it('should insert an added attendee and delete a removed one while leaving the unchanged attendee alone', async () => {
const eventExternalId = `google-calendar-event-${randomUUID()}`;
const title = `Calendar event ${randomUUID()}`;
const keptAttendee = `attendee-kept-${randomUUID()}@acme.com`;
const removedAttendee = `attendee-removed-${randomUUID()}@acme.com`;
const addedAttendee = `attendee-added-${randomUUID()}@acme.com`;
await importEvent(
googleCalendarEvent({
id: eventExternalId,
summary: title,
attendees: [{ email: keptAttendee }, { email: removedAttendee }],
}),
);
const [event] = await findEventsByTitle(title);
const initialParticipants = await findParticipantsByCalendarEventId(
event.id,
);
expect(
initialParticipants.map((participant) => participant.handle),
).toEqual([keptAttendee, removedAttendee].sort());
const initialKeptParticipant = initialParticipants.find(
(participant) => participant.handle === keptAttendee,
);
await importEvent(
googleCalendarEvent({
id: eventExternalId,
summary: title,
attendees: [{ email: keptAttendee }, { email: addedAttendee }],
}),
);
const participants = await findParticipantsByCalendarEventId(event.id);
expect(participants.map((participant) => participant.handle)).toEqual(
[keptAttendee, addedAttendee].sort(),
);
const keptParticipant = participants.find(
(participant) => participant.handle === keptAttendee,
);
expect(keptParticipant?.id).toBe(initialKeptParticipant?.id);
}, 180000);
it('should insert every participant when a single import spans more than one insert chunk', async () => {
const titlePrefix = `Calendar event ${randomUUID()}`;
const attendeeSuffix = `chunked-${randomUUID()}@acme.com`;
const events = Array.from({ length: 100 }, (_unused, eventIndex) =>
googleCalendarEvent({
id: `google-calendar-event-${eventIndex}-${randomUUID()}`,
summary: `${titlePrefix} ${eventIndex}`,
attendees: [
{ email: `a-${eventIndex}-${attendeeSuffix}` },
{ email: `b-${eventIndex}-${attendeeSuffix}` },
{ email: `c-${eventIndex}-${attendeeSuffix}` },
],
}),
);
await importEvents(events);
expect(await countParticipantsByHandleSuffix(attendeeSuffix)).toBe(300);
}, 300000);
it('should update every participant when a re-import spans more than one update chunk', async () => {
const titlePrefix = `Calendar event ${randomUUID()}`;
const eventIdSuffix = randomUUID();
const attendeeSuffix = `rechunked-${randomUUID()}@acme.com`;
const buildEvents = (displayName: string): calendar_v3.Schema$Event[] =>
Array.from({ length: 100 }, (_unused, eventIndex) =>
googleCalendarEvent({
id: `google-calendar-event-${eventIndex}-${eventIdSuffix}`,
summary: `${titlePrefix} ${eventIndex}`,
attendees: [
{ email: `a-${eventIndex}-${attendeeSuffix}`, displayName },
{ email: `b-${eventIndex}-${attendeeSuffix}`, displayName },
{ email: `c-${eventIndex}-${attendeeSuffix}`, displayName },
],
}),
);
await importEvents(buildEvents('Before'));
expect(
await countParticipantsByHandleSuffixAndDisplayName(
attendeeSuffix,
'Before',
),
).toBe(300);
await importEvents(buildEvents('After'));
expect(await countParticipantsByHandleSuffix(attendeeSuffix)).toBe(300);
expect(
await countParticipantsByHandleSuffixAndDisplayName(
attendeeSuffix,
'After',
),
).toBe(300);
}, 300000);
it('should update exactly one row and leave the other untouched when two participants share a handle on the same event', async () => {
const eventExternalId = `google-calendar-event-${randomUUID()}`;
const title = `Calendar event ${randomUUID()}`;
const attendee = `attendee-${randomUUID()}@acme.com`;
await importEvent(
googleCalendarEvent({
id: eventExternalId,
summary: title,
attendees: [{ email: attendee, displayName: 'Original' }],
}),
);
const [event] = await findEventsByTitle(title);
const duplicateResponse = await makeGraphqlAPIRequest(
createOneOperationFactory({
objectMetadataSingularName: 'calendarEventParticipant',
gqlFields: 'id',
data: {
calendarEventId: event.id,
handle: attendee,
displayName: 'Duplicate',
},
}),
);
expect(duplicateResponse.body.errors).toBeUndefined();
expect(await findParticipantsByCalendarEventId(event.id)).toHaveLength(2);
await importEvent(
googleCalendarEvent({
id: eventExternalId,
summary: title,
attendees: [{ email: attendee, displayName: 'Reconciled' }],
}),
);
const participants = await findParticipantsByCalendarEventId(event.id);
expect(participants).toHaveLength(2);
expect(
participants.filter(
(participant) => participant.displayName === 'Reconciled',
),
).toHaveLength(1);
}, 240000);
});

View File

@@ -0,0 +1,76 @@
import { randomUUID } from 'node:crypto';
import {
CalendarChannelSyncStage,
ConnectedAccountProvider,
} from 'twenty-shared/types';
import { googleCalendarEvent } from 'test/integration/google/mocks/google-calendar-event.util';
import { setupGoogleMock } from 'test/integration/google/mocks/setup-google-mock.util';
import { connectMessagingAccount } from 'test/integration/utils/connect-messaging-account.util';
import { findImportedCalendarEventTitles } from 'test/integration/utils/find-imported-records.util';
import { queryCalendarChannel } from 'test/integration/utils/query-messaging.util';
import { runCalendarChannelEventsImport } from 'test/integration/utils/run-calendar-channel-events-import.util';
import { runCalendarChannelListFetch } from 'test/integration/utils/run-calendar-channel-list-fetch.util';
const HANDLE = 'google-calendar-events-import@apple.dev';
describe('Google calendar events import (integration)', () => {
const gmail = setupGoogleMock({ handle: HANDLE });
let channel: Awaited<ReturnType<typeof connectMessagingAccount>>;
beforeAll(async () => {
channel = await connectMessagingAccount({
provider: ConnectedAccountProvider.GOOGLE,
handle: HANDLE,
});
}, 60000);
afterAll(async () => {
await channel?.cleanup().catch(() => undefined);
});
it('imports calendar events through the real list-fetch and import pipeline', async () => {
const eventTitle = `Calendar event ${randomUUID()}`;
gmail.serveCalendarEvents([googleCalendarEvent({ summary: eventTitle })]);
await runCalendarChannelListFetch(channel.calendarChannelId);
const channelState = await queryCalendarChannel(channel);
expect(channelState.syncStage).toBe(
CalendarChannelSyncStage.CALENDAR_EVENTS_IMPORT_PENDING,
);
await runCalendarChannelEventsImport(channel.calendarChannelId);
expect(await findImportedCalendarEventTitles([eventTitle])).toEqual([
eventTitle,
]);
}, 60000);
it('imports a newly created event through the sync-token incremental fetch', async () => {
const newEventTitle = `Calendar event ${randomUUID()}`;
gmail.serveCalendarEvents(
[googleCalendarEvent({ summary: newEventTitle })],
{ nextSyncToken: 'mock-calendar-sync-token-2' },
);
await runCalendarChannelListFetch(channel.calendarChannelId);
const channelState = await queryCalendarChannel(channel);
expect(channelState.syncStage).toBe(
CalendarChannelSyncStage.CALENDAR_EVENTS_IMPORT_PENDING,
);
await runCalendarChannelEventsImport(channel.calendarChannelId);
expect(await findImportedCalendarEventTitles([newEventTitle])).toEqual([
newEventTitle,
]);
}, 60000);
});

View File

@@ -0,0 +1,210 @@
import {
CalendarChannelSyncStage,
CalendarChannelSyncStatus,
ConnectedAccountProvider,
} from 'twenty-shared/types';
import { CalendarChannelEntity } from 'src/engine/metadata-modules/calendar-channel/entities/calendar-channel.entity';
import { ConnectedAccountEntity } from 'src/engine/metadata-modules/connected-account/entities/connected-account.entity';
import {
type GoogleApiFailure,
setupGoogleMock,
} from 'test/integration/google/mocks/setup-google-mock.util';
import { connectMessagingAccount } from 'test/integration/utils/connect-messaging-account.util';
import { getCoreRepository } from 'test/integration/utils/get-core-repository.util';
import {
queryCalendarChannel,
queryConnectedAccount,
} from 'test/integration/utils/query-messaging.util';
import { resetCalendarChannelSyncState } from 'test/integration/utils/reset-channel-sync-state.util';
import { runCalendarChannelListFetch } from 'test/integration/utils/run-calendar-channel-list-fetch.util';
const HANDLE = 'calendar-import-error-mapping@apple.dev';
// Providers return 401 and 403 transiently, so revoking on them disconnects
// working accounts. Only a response that proves the grant is gone revokes.
const INSUFFICIENT_PERMISSIONS_FAILURES: [string, GoogleApiFailure][] = [
[
'a 400 invalid_grant response',
{
status: 400,
reason: 'invalid_grant',
message: 'Token has been expired or revoked.',
},
],
];
const TEMPORARY_FAILURES: [string, GoogleApiFailure][] = [
[
'a 401 unauthorized response',
{ status: 401, reason: 'authError', message: 'Invalid Credentials' },
],
[
'a 403 response that is not a rate limit',
{
status: 403,
reason: 'insufficientPermissions',
message: 'Request had insufficient authentication scopes.',
},
],
[
'a 500 response with an unmapped reason',
{ status: 500, reason: 'somethingElse', message: 'Internal error' },
],
[
'a response with an unmapped status',
{ status: 418, reason: 'teapot', message: 'I am a teapot' },
],
[
'a 403 rateLimitExceeded response',
{
status: 403,
reason: 'rateLimitExceeded',
message: 'Rate Limit Exceeded',
},
],
[
'a 403 userRateLimitExceeded response',
{
status: 403,
reason: 'userRateLimitExceeded',
message: 'User Rate Limit Exceeded',
},
],
[
'a 400 failedPrecondition response',
{
status: 400,
reason: 'failedPrecondition',
message: 'Precondition check failed.',
},
],
[
'a 500 backendError response',
{ status: 500, reason: 'backendError', message: 'Backend Error' },
],
[
'a 500 internal_failure response',
{ status: 500, reason: 'internal_failure', message: 'Internal failure' },
],
];
const UNKNOWN_FAILURES: [string, GoogleApiFailure][] = [
[
'a 400 response with an unmapped reason',
{ status: 400, reason: 'invalidArgument', message: 'Invalid query' },
],
];
describe('Google Calendar import error mapping (integration)', () => {
const google = setupGoogleMock({ handle: HANDLE });
let channel: Awaited<ReturnType<typeof connectMessagingAccount>>;
beforeAll(async () => {
channel = await connectMessagingAccount({
provider: ConnectedAccountProvider.GOOGLE,
handle: HANDLE,
});
}, 60000);
afterAll(async () => {
await channel?.cleanup().catch(() => undefined);
});
beforeEach(async () => {
await resetCalendarChannelSyncState(channel.calendarChannelId);
await getCoreRepository<ConnectedAccountEntity>(
ConnectedAccountEntity,
).update({ id: channel.connectedAccountId }, { authFailedAt: null });
});
it.each(INSUFFICIENT_PERMISSIONS_FAILURES)(
'fails the calendar channel as insufficient-permissions on %s',
async (_label, failure) => {
google.failCalendarEventList(failure);
await runCalendarChannelListFetch(channel.calendarChannelId);
const channelState = await queryCalendarChannel(channel);
expect(channelState.syncStatus).toBe(
CalendarChannelSyncStatus.FAILED_INSUFFICIENT_PERMISSIONS,
);
expect(channelState.syncStage).toBe(CalendarChannelSyncStage.FAILED);
const account = await queryConnectedAccount(channel.connectedAccountId);
expect(account.authFailedAt).not.toBeNull();
},
60000,
);
it.each(TEMPORARY_FAILURES)(
'throttles the calendar channel without failing it on %s',
async (_label, failure) => {
google.failCalendarEventList(failure);
await runCalendarChannelListFetch(channel.calendarChannelId);
const channelState = await queryCalendarChannel(channel);
expect(channelState.throttleFailureCount).toBe(1);
expect(channelState.syncStage).toBe(
CalendarChannelSyncStage.CALENDAR_EVENT_LIST_FETCH_PENDING,
);
expect(channelState.syncStatus).not.toBe(
CalendarChannelSyncStatus.FAILED_UNKNOWN,
);
expect(channelState.syncStatus).not.toBe(
CalendarChannelSyncStatus.FAILED_INSUFFICIENT_PERMISSIONS,
);
},
60000,
);
it.each(UNKNOWN_FAILURES)(
'fails the calendar channel as unknown on %s',
async (_label, failure) => {
google.failCalendarEventList(failure);
await runCalendarChannelListFetch(channel.calendarChannelId);
const channelState = await queryCalendarChannel(channel);
expect(channelState.syncStatus).toBe(
CalendarChannelSyncStatus.FAILED_UNKNOWN,
);
expect(channelState.syncStage).toBe(CalendarChannelSyncStage.FAILED);
},
60000,
);
it('leaves the calendar channel untouched on a 404 during list fetch', async () => {
google.failCalendarEventList({
status: 404,
reason: 'notFound',
message: 'Not Found',
});
await runCalendarChannelListFetch(channel.calendarChannelId);
const channelState = await queryCalendarChannel(channel);
expect(channelState.syncStatus).not.toBe(
CalendarChannelSyncStatus.FAILED_UNKNOWN,
);
expect(channelState.syncStatus).not.toBe(
CalendarChannelSyncStatus.FAILED_INSUFFICIENT_PERMISSIONS,
);
expect(channelState.throttleFailureCount).toBe(0);
const storedChannel = await getCoreRepository<CalendarChannelEntity>(
CalendarChannelEntity,
).findOneByOrFail({ id: channel.calendarChannelId });
expect(storedChannel.syncCursor).toBe('reset-cursor');
}, 60000);
});

View File

@@ -0,0 +1,188 @@
import { randomUUID } from 'node:crypto';
import { ConnectedAccountProvider } from 'twenty-shared/types';
import { googleCalendarEvent } from 'test/integration/google/mocks/google-calendar-event.util';
import { setupGoogleMock } from 'test/integration/google/mocks/setup-google-mock.util';
import { connectMessagingAccount } from 'test/integration/utils/connect-messaging-account.util';
import { findCalendarEventAssociationChannelIds } from 'test/integration/utils/find-calendar-event-association-channel-ids.util';
import { findImportedCalendarEventTitles } from 'test/integration/utils/find-imported-records.util';
import { runCalendarChannelEventsImport } from 'test/integration/utils/run-calendar-channel-events-import.util';
import { runCalendarChannelListFetch } from 'test/integration/utils/run-calendar-channel-list-fetch.util';
const HANDLE = 'google-calendar-orphan-cleanup@apple.dev';
const OTHER_HANDLE = 'google-calendar-orphan-cleanup-other@apple.dev';
const syncToken = () => `orphan-cleanup-sync-token-${randomUUID()}`;
const runCalendarSync = async (calendarChannelId: string): Promise<void> => {
await runCalendarChannelListFetch(calendarChannelId);
await runCalendarChannelEventsImport(calendarChannelId);
};
describe('Google calendar orphan cleanup (integration)', () => {
const google = setupGoogleMock({ handle: HANDLE });
let channel: Awaited<ReturnType<typeof connectMessagingAccount>>;
let otherChannel: Awaited<ReturnType<typeof connectMessagingAccount>>;
beforeAll(async () => {
channel = await connectMessagingAccount({
provider: ConnectedAccountProvider.GOOGLE,
handle: HANDLE,
});
google.actAsAccount(OTHER_HANDLE);
otherChannel = await connectMessagingAccount({
provider: ConnectedAccountProvider.GOOGLE,
handle: OTHER_HANDLE,
});
google.actAsAccount(HANDLE);
}, 120000);
afterAll(async () => {
await otherChannel?.cleanup().catch(() => undefined);
await channel?.cleanup().catch(() => undefined);
});
it('deletes the calendar event once its last association is removed', async () => {
const eventExternalId = `google-calendar-event-${randomUUID()}`;
const eventTitle = `Orphan probe ${randomUUID()}`;
google.serveCalendarEvents(
[googleCalendarEvent({ id: eventExternalId, summary: eventTitle })],
{ nextSyncToken: syncToken() },
);
await runCalendarSync(channel.calendarChannelId);
expect(await findImportedCalendarEventTitles([eventTitle])).toEqual([
eventTitle,
]);
expect(
await findCalendarEventAssociationChannelIds(eventExternalId),
).toEqual([channel.calendarChannelId]);
google.serveCalendarEvents(
[
googleCalendarEvent({
id: eventExternalId,
summary: eventTitle,
status: 'cancelled',
}),
],
{ nextSyncToken: syncToken() },
);
await runCalendarSync(channel.calendarChannelId);
expect(
await findCalendarEventAssociationChannelIds(eventExternalId),
).toEqual([]);
expect(await findImportedCalendarEventTitles([eventTitle])).toEqual([]);
}, 60000);
it('keeps the events of the same channel that were not cancelled', async () => {
const cancelledExternalId = `google-calendar-event-${randomUUID()}`;
const cancelledTitle = `Orphan probe cancelled ${randomUUID()}`;
const survivingTitle = `Orphan probe surviving ${randomUUID()}`;
google.serveCalendarEvents(
[
googleCalendarEvent({
id: cancelledExternalId,
summary: cancelledTitle,
}),
googleCalendarEvent({ summary: survivingTitle }),
],
{ nextSyncToken: syncToken() },
);
await runCalendarSync(channel.calendarChannelId);
expect(
await findImportedCalendarEventTitles([cancelledTitle, survivingTitle]),
).toEqual([cancelledTitle, survivingTitle].sort());
google.serveCalendarEvents(
[
googleCalendarEvent({
id: cancelledExternalId,
summary: cancelledTitle,
status: 'cancelled',
}),
],
{ nextSyncToken: syncToken() },
);
await runCalendarSync(channel.calendarChannelId);
expect(await findImportedCalendarEventTitles([cancelledTitle])).toEqual([]);
expect(await findImportedCalendarEventTitles([survivingTitle])).toEqual([
survivingTitle,
]);
}, 60000);
it('keeps another channel copy of the same external event', async () => {
const sharedExternalId = `google-calendar-event-${randomUUID()}`;
const sharedTitle = `Orphan probe shared ${randomUUID()}`;
google.serveCalendarEvents(
[googleCalendarEvent({ id: sharedExternalId, summary: sharedTitle })],
{ nextSyncToken: syncToken() },
);
await runCalendarSync(channel.calendarChannelId);
await runCalendarSync(otherChannel.calendarChannelId);
expect(
await findCalendarEventAssociationChannelIds(sharedExternalId),
).toEqual(
[channel.calendarChannelId, otherChannel.calendarChannelId].sort(),
);
google.serveCalendarEvents(
[
googleCalendarEvent({
id: sharedExternalId,
summary: sharedTitle,
status: 'cancelled',
}),
],
{ nextSyncToken: syncToken() },
);
await runCalendarSync(channel.calendarChannelId);
expect(
await findCalendarEventAssociationChannelIds(sharedExternalId),
).toEqual([otherChannel.calendarChannelId]);
expect(await findImportedCalendarEventTitles([sharedTitle])).toEqual([
sharedTitle,
]);
}, 120000);
it('keeps previously imported events when the delta has no deletions', async () => {
const existingTitle = `Orphan probe existing ${randomUUID()}`;
const addedTitle = `Orphan probe added ${randomUUID()}`;
google.serveCalendarEvents(
[googleCalendarEvent({ summary: existingTitle })],
{ nextSyncToken: syncToken() },
);
await runCalendarSync(channel.calendarChannelId);
google.serveCalendarEvents([googleCalendarEvent({ summary: addedTitle })], {
nextSyncToken: syncToken(),
});
await runCalendarSync(channel.calendarChannelId);
expect(
await findImportedCalendarEventTitles([existingTitle, addedTitle]),
).toEqual([existingTitle, addedTitle].sort());
}, 60000);
});

View File

@@ -0,0 +1,90 @@
import {
CalendarChannelSyncStage,
ConnectedAccountProvider,
} from 'twenty-shared/types';
import { CalendarChannelEntity } from 'src/engine/metadata-modules/calendar-channel/entities/calendar-channel.entity';
import { CalendarOngoingStaleCronJob } from 'src/modules/calendar/calendar-event-import-manager/crons/jobs/calendar-ongoing-stale.cron.job';
import { setupGoogleMock } from 'test/integration/google/mocks/setup-google-mock.util';
import { connectMessagingAccount } from 'test/integration/utils/connect-messaging-account.util';
import { getCoreRepository } from 'test/integration/utils/get-core-repository.util';
import { queryCalendarChannel } from 'test/integration/utils/query-messaging.util';
import { runSyncCron } from 'test/integration/utils/run-sync-cron.util';
const STALE_HANDLE = 'calendar-stale-sync@apple.dev';
const RECENT_HANDLE = 'calendar-recent-sync@apple.dev';
const STALE_STARTED_AT = new Date(Date.now() - 61 * 60 * 1000);
const RECENT_STARTED_AT = new Date(Date.now() - 60 * 1000);
describe('Calendar stale-sync recovery (integration)', () => {
const gmail = setupGoogleMock({ handle: STALE_HANDLE });
let staleChannel: Awaited<ReturnType<typeof connectMessagingAccount>>;
let recentChannel: Awaited<ReturnType<typeof connectMessagingAccount>>;
beforeAll(async () => {
staleChannel = await connectMessagingAccount({
provider: ConnectedAccountProvider.GOOGLE,
handle: STALE_HANDLE,
});
gmail.actAsAccount(RECENT_HANDLE);
recentChannel = await connectMessagingAccount({
provider: ConnectedAccountProvider.GOOGLE,
handle: RECENT_HANDLE,
});
for (const connectedChannel of [staleChannel, recentChannel]) {
const channelState = await queryCalendarChannel(connectedChannel);
expect(channelState.syncStage).toBe(
CalendarChannelSyncStage.CALENDAR_EVENT_LIST_FETCH_PENDING,
);
}
}, 120000);
afterAll(async () => {
await staleChannel?.cleanup().catch(() => undefined);
await recentChannel?.cleanup().catch(() => undefined);
});
it('resets a stale ongoing channel to pending and leaves a recent one running', async () => {
const calendarChannelRepository = getCoreRepository<CalendarChannelEntity>(
CalendarChannelEntity,
);
await calendarChannelRepository.update(
{ id: staleChannel.calendarChannelId },
{
syncStage: CalendarChannelSyncStage.CALENDAR_EVENT_LIST_FETCH_ONGOING,
syncStageStartedAt: STALE_STARTED_AT,
},
);
await calendarChannelRepository.update(
{ id: recentChannel.calendarChannelId },
{
syncStage: CalendarChannelSyncStage.CALENDAR_EVENT_LIST_FETCH_ONGOING,
syncStageStartedAt: RECENT_STARTED_AT,
},
);
await runSyncCron(CalendarOngoingStaleCronJob);
const staleChannelAfter = await queryCalendarChannel(staleChannel);
expect(staleChannelAfter.syncStage).toBe(
CalendarChannelSyncStage.CALENDAR_EVENT_LIST_FETCH_PENDING,
);
expect(staleChannelAfter.syncStageStartedAt).toBeNull();
const recentChannelAfter = await queryCalendarChannel(recentChannel);
expect(recentChannelAfter.syncStage).toBe(
CalendarChannelSyncStage.CALENDAR_EVENT_LIST_FETCH_ONGOING,
);
}, 60000);
});

View File

@@ -0,0 +1,151 @@
import {
CalendarChannelSyncStage,
CalendarChannelSyncStatus,
ConnectedAccountProvider,
} from 'twenty-shared/types';
import { CalendarChannelEntity } from 'src/engine/metadata-modules/calendar-channel/entities/calendar-channel.entity';
import { ConnectedAccountEntity } from 'src/engine/metadata-modules/connected-account/entities/connected-account.entity';
import { CALENDAR_THROTTLE_MAX_ATTEMPTS } from 'src/modules/calendar/calendar-event-import-manager/constants/calendar-throttle-max-attempts';
import { CalendarEventListFetchCronJob } from 'src/modules/calendar/calendar-event-import-manager/crons/jobs/calendar-event-list-fetch.cron.job';
import { CalendarRelaunchFailedCalendarChannelsCronJob } from 'src/modules/calendar/calendar-event-import-manager/crons/jobs/calendar-relaunch-failed-calendar-channels.cron.job';
import { setupGoogleMock } from 'test/integration/google/mocks/setup-google-mock.util';
import { connectMessagingAccount } from 'test/integration/utils/connect-messaging-account.util';
import { getCoreRepository } from 'test/integration/utils/get-core-repository.util';
import {
queryCalendarChannel,
queryConnectedAccount,
updateCalendarChannel,
} from 'test/integration/utils/query-messaging.util';
import { runSyncCron } from 'test/integration/utils/run-sync-cron.util';
const THROTTLED_HANDLE = 'calendar-throttled@apple.dev';
const REVOKED_HANDLE = 'calendar-revoked@apple.dev';
const ONE_HOUR_AGO = new Date(Date.now() - 60 * 60 * 1000);
const EXPIRED_CREDENTIALS_AT = new Date(Date.now() - 56 * 60 * 1000);
describe('Calendar sync failure lifecycle (integration)', () => {
const gmail = setupGoogleMock({ handle: THROTTLED_HANDLE });
let throttledChannel: Awaited<ReturnType<typeof connectMessagingAccount>>;
let revokedChannel: Awaited<ReturnType<typeof connectMessagingAccount>>;
beforeAll(async () => {
throttledChannel = await connectMessagingAccount({
provider: ConnectedAccountProvider.GOOGLE,
handle: THROTTLED_HANDLE,
});
gmail.actAsAccount(REVOKED_HANDLE);
revokedChannel = await connectMessagingAccount({
provider: ConnectedAccountProvider.GOOGLE,
handle: REVOKED_HANDLE,
});
await updateCalendarChannel(revokedChannel.calendarChannelId, {
isSyncEnabled: false,
});
}, 120000);
afterAll(async () => {
await throttledChannel?.cleanup().catch(() => undefined);
await revokedChannel?.cleanup().catch(() => undefined);
});
it('counts a throttle failure on a 429 and keeps the channel alive', async () => {
gmail.rateLimitCalendarEventList();
await runSyncCron(CalendarEventListFetchCronJob);
const channelState = await queryCalendarChannel(throttledChannel);
expect(channelState.throttleFailureCount).toBe(1);
expect(channelState.syncStatus).not.toBe(
CalendarChannelSyncStatus.FAILED_UNKNOWN,
);
}, 60000);
it('fails the channel as unknown once the throttle attempts are exhausted', async () => {
gmail.rateLimitCalendarEventList();
const calendarChannelRepository = getCoreRepository<CalendarChannelEntity>(
CalendarChannelEntity,
);
let channelState = await queryCalendarChannel(throttledChannel);
for (
let attempt = channelState.throttleFailureCount;
attempt <= CALENDAR_THROTTLE_MAX_ATTEMPTS &&
channelState.syncStatus !== CalendarChannelSyncStatus.FAILED_UNKNOWN;
attempt++
) {
await calendarChannelRepository.update(
{ id: throttledChannel.calendarChannelId },
{ syncStageStartedAt: ONE_HOUR_AGO },
);
await runSyncCron(CalendarEventListFetchCronJob);
channelState = await queryCalendarChannel(throttledChannel);
}
expect(channelState.syncStatus).toBe(
CalendarChannelSyncStatus.FAILED_UNKNOWN,
);
expect(channelState.syncStage).toBe(CalendarChannelSyncStage.FAILED);
}, 120000);
it('fails the channel as insufficient-permissions when the refresh token is declined', async () => {
await updateCalendarChannel(revokedChannel.calendarChannelId, {
isSyncEnabled: true,
});
await getCoreRepository<ConnectedAccountEntity>(
ConnectedAccountEntity,
).update(
{ id: revokedChannel.connectedAccountId },
{ lastCredentialsRefreshedAt: EXPIRED_CREDENTIALS_AT },
);
gmail.declineTokenRefresh();
await runSyncCron(CalendarEventListFetchCronJob);
const channelState = await queryCalendarChannel(revokedChannel);
expect(channelState.syncStatus).toBe(
CalendarChannelSyncStatus.FAILED_INSUFFICIENT_PERMISSIONS,
);
expect(channelState.syncStage).toBe(CalendarChannelSyncStage.FAILED);
const account = await queryConnectedAccount(
revokedChannel.connectedAccountId,
);
expect(account.authFailedAt).not.toBeNull();
}, 60000);
it('relaunches the unknown-failure channel and leaves the permissions-failure channel untouched', async () => {
await runSyncCron(CalendarRelaunchFailedCalendarChannelsCronJob);
const relaunchedChannel = await queryCalendarChannel(throttledChannel);
expect(relaunchedChannel.syncStatus).toBe(CalendarChannelSyncStatus.ACTIVE);
expect(relaunchedChannel.syncStage).toBe(
CalendarChannelSyncStage.CALENDAR_EVENT_LIST_FETCH_PENDING,
);
expect(relaunchedChannel.throttleFailureCount).toBe(0);
expect(relaunchedChannel.syncStageStartedAt).toBeNull();
const untouchedChannel = await queryCalendarChannel(revokedChannel);
expect(untouchedChannel.syncStatus).toBe(
CalendarChannelSyncStatus.FAILED_INSUFFICIENT_PERMISSIONS,
);
expect(untouchedChannel.syncStage).toBe(CalendarChannelSyncStage.FAILED);
}, 60000);
});

View File

@@ -0,0 +1,270 @@
import { randomUUID } from 'node:crypto';
import {
CalendarChannelSyncStage,
CalendarChannelSyncStatus,
ConnectedAccountProvider,
} from 'twenty-shared/types';
import { POSTGRESQL_ERROR_CODES } from 'src/engine/api/graphql/workspace-query-runner/constants/postgres-error-codes.constants';
import { CalendarEventParticipantService } from 'src/modules/calendar/calendar-event-participant-manager/services/calendar-event-participant.service';
import { googleCalendarEvent } from 'test/integration/google/mocks/google-calendar-event.util';
import { setupGoogleMock } from 'test/integration/google/mocks/setup-google-mock.util';
import { connectMessagingAccount } from 'test/integration/utils/connect-messaging-account.util';
import { getAppProviderByClassName } from 'test/integration/utils/get-app-provider-by-class-name.util';
import { raiseSqlState } from 'test/integration/utils/raise-sql-state.util';
import { readBackendState } from 'test/integration/utils/read-backend-state.util';
import { queryCalendarChannel } from 'test/integration/utils/query-messaging.util';
import { resetCalendarChannelSyncState } from 'test/integration/utils/reset-channel-sync-state.util';
import { runCalendarChannelEventsImport } from 'test/integration/utils/run-calendar-channel-events-import.util';
import { runCalendarChannelListFetch } from 'test/integration/utils/run-calendar-channel-list-fetch.util';
const HANDLE = 'calendar-transient-database-error@apple.dev';
// The error has to be raised by Postgres itself: an Error built in the jest
// realm is not an `instanceof Error` for the application realm the app runs in.
describe('Calendar import transient database errors (integration)', () => {
const google = setupGoogleMock({ handle: HANDLE });
let channel: Awaited<ReturnType<typeof connectMessagingAccount>>;
let calendarEventParticipantService: CalendarEventParticipantService;
const fetchEvent = async ({
eventId,
title,
attendees,
}: {
eventId: string;
title: string;
attendees: string[];
}): Promise<void> => {
google.serveCalendarEvents(
[
googleCalendarEvent({
id: eventId,
summary: title,
attendees: attendees.map((email) => ({ email })),
}),
],
{ nextSyncToken: `sync-token-${randomUUID()}` },
);
await resetCalendarChannelSyncState(channel.calendarChannelId, '');
await runCalendarChannelListFetch(channel.calendarChannelId);
};
const fetchOneEvent = async (): Promise<void> => {
await fetchEvent({
eventId: `google-calendar-event-${randomUUID()}`,
title: `Calendar event ${randomUUID()}`,
attendees: [`attendee-${randomUUID()}@acme.com`],
});
};
beforeAll(async () => {
channel = await connectMessagingAccount({
provider: ConnectedAccountProvider.GOOGLE,
handle: HANDLE,
});
calendarEventParticipantService =
getAppProviderByClassName<CalendarEventParticipantService>(
'CalendarEventParticipantService',
);
}, 60000);
afterAll(async () => {
await channel?.cleanup().catch(() => undefined);
});
afterEach(() => {
jest.restoreAllMocks();
});
it('should fail the calendar channel as unknown when a unique violation aborts the import transaction', async () => {
await fetchOneEvent();
jest
.spyOn(calendarEventParticipantService, 'writeCalendarEventParticipants')
.mockImplementation(async ({ transactionScope }) => {
await transactionScope.executeRawQuery(
raiseSqlState(POSTGRESQL_ERROR_CODES.UNIQUE_VIOLATION),
);
});
await runCalendarChannelEventsImport(channel.calendarChannelId);
const channelState = await queryCalendarChannel(channel);
expect(channelState.syncStatus).toBe(
CalendarChannelSyncStatus.FAILED_UNKNOWN,
);
expect(channelState.syncStage).toBe(CalendarChannelSyncStage.FAILED);
expect(channelState.throttleFailureCount).toBe(0);
}, 120000);
it('should reschedule the calendar channel when the import transaction is killed by the idle-in-transaction timeout', async () => {
await fetchOneEvent();
jest
.spyOn(calendarEventParticipantService, 'writeCalendarEventParticipants')
.mockImplementation(async ({ transactionScope }) => {
await transactionScope.executeRawQuery(
raiseSqlState(
POSTGRESQL_ERROR_CODES.IDLE_IN_TRANSACTION_SESSION_TIMEOUT,
),
);
});
await runCalendarChannelEventsImport(channel.calendarChannelId);
const channelState = await queryCalendarChannel(channel);
expect(channelState.throttleFailureCount).toBe(1);
expect(channelState.syncStage).toBe(
CalendarChannelSyncStage.CALENDAR_EVENTS_IMPORT_PENDING,
);
expect(channelState.syncStatus).toBe(CalendarChannelSyncStatus.ONGOING);
}, 120000);
it('should have ended the import transaction before the contact creation job is enqueued and the participants are matched', async () => {
await fetchOneEvent();
const writeCalendarEventParticipants =
calendarEventParticipantService.writeCalendarEventParticipants.bind(
calendarEventParticipantService,
);
const matchParticipantsAndEnqueueContactCreationJob =
calendarEventParticipantService.matchParticipantsAndEnqueueContactCreationJob.bind(
calendarEventParticipantService,
);
let importBackendPid: number | undefined;
let importBackendStateWhileMatching: string | undefined;
jest
.spyOn(calendarEventParticipantService, 'writeCalendarEventParticipants')
.mockImplementation(async (args) => {
const backends = await args.transactionScope.executeRawQuery(
'SELECT pg_backend_pid() AS pid',
);
importBackendPid = Number(backends[0].pid);
return writeCalendarEventParticipants(args);
});
jest
.spyOn(
calendarEventParticipantService,
'matchParticipantsAndEnqueueContactCreationJob',
)
.mockImplementation(async (args) => {
importBackendStateWhileMatching = await readBackendState(
importBackendPid ?? 0,
);
return matchParticipantsAndEnqueueContactCreationJob(args);
});
await runCalendarChannelEventsImport(channel.calendarChannelId);
expect(importBackendPid).toEqual(expect.any(Number));
expect(importBackendStateWhileMatching).toBeDefined();
expect(importBackendStateWhileMatching).not.toBe('idle in transaction');
}, 120000);
it('should return only the newly saved participants without appending to the caller participant list when an existing event gains an attendee', async () => {
const eventId = `google-calendar-event-${randomUUID()}`;
const title = `Calendar event ${randomUUID()}`;
const knownAttendee = `attendee-known-${randomUUID()}@acme.com`;
const newAttendee = `attendee-new-${randomUUID()}@acme.com`;
await fetchEvent({ eventId, title, attendees: [knownAttendee] });
await runCalendarChannelEventsImport(channel.calendarChannelId);
await fetchEvent({
eventId,
title,
attendees: [knownAttendee, newAttendee],
});
const writeCalendarEventParticipants =
calendarEventParticipantService.writeCalendarEventParticipants.bind(
calendarEventParticipantService,
);
let insertedHandles: string[] = [];
let updatedParticipantCount = -1;
jest
.spyOn(calendarEventParticipantService, 'writeCalendarEventParticipants')
.mockImplementation(async (args) => {
insertedHandles = args.operations.participantsToInsert.map(
(participant) => participant.handle,
);
updatedParticipantCount = args.operations.participantsToUpdate.length;
return writeCalendarEventParticipants(args);
});
await runCalendarChannelEventsImport(channel.calendarChannelId);
expect(insertedHandles).toEqual([newAttendee]);
expect(updatedParticipantCount).toBe(1);
}, 180000);
it('should read and compute outside the transaction and only write inside it', async () => {
await fetchOneEvent();
const calls: string[] = [];
const findCalendarEventParticipantsByCalendarEventIds =
calendarEventParticipantService.findCalendarEventParticipantsByCalendarEventIds.bind(
calendarEventParticipantService,
);
const writeCalendarEventParticipants =
calendarEventParticipantService.writeCalendarEventParticipants.bind(
calendarEventParticipantService,
);
const matchParticipantsAndEnqueueContactCreationJob =
calendarEventParticipantService.matchParticipantsAndEnqueueContactCreationJob.bind(
calendarEventParticipantService,
);
jest
.spyOn(
calendarEventParticipantService,
'findCalendarEventParticipantsByCalendarEventIds',
)
.mockImplementation(async (args) => {
calls.push('read');
return findCalendarEventParticipantsByCalendarEventIds(args);
});
jest
.spyOn(calendarEventParticipantService, 'writeCalendarEventParticipants')
.mockImplementation(async (args) => {
calls.push('write');
return writeCalendarEventParticipants(args);
});
jest
.spyOn(
calendarEventParticipantService,
'matchParticipantsAndEnqueueContactCreationJob',
)
.mockImplementation(async (args) => {
calls.push('match');
return matchParticipantsAndEnqueueContactCreationJob(args);
});
await runCalendarChannelEventsImport(channel.calendarChannelId);
expect(calls).toEqual(['read', 'write', 'match']);
}, 120000);
});

View File

@@ -0,0 +1,127 @@
import { randomUUID } from 'node:crypto';
import {
CalendarChannelSyncStage,
ConnectedAccountProvider,
} from 'twenty-shared/types';
import { CALENDAR_EVENT_WEBHOOK_SYNC_INLINE_IMPORT_MAX_EVENTS } from 'src/modules/connected-account-sync-webhooks/calendar-event-webhook-sync/constants/calendar-event-webhook-sync-inline-import-max-events.constant';
import { googleCalendarEvent } from 'test/integration/google/mocks/google-calendar-event.util';
import { setupGoogleMock } from 'test/integration/google/mocks/setup-google-mock.util';
import { connectMessagingAccount } from 'test/integration/utils/connect-messaging-account.util';
import { findImportedCalendarEventTitles } from 'test/integration/utils/find-imported-records.util';
import { queryCalendarChannel } from 'test/integration/utils/query-messaging.util';
import { runCalendarChannelEventsImport } from 'test/integration/utils/run-calendar-channel-events-import.util';
import { runCalendarChannelWebhookSync } from 'test/integration/utils/run-calendar-channel-webhook-sync.util';
const HANDLE = 'google-calendar-webhook-sync@apple.dev';
describe('Google calendar webhook sync (integration)', () => {
const google = setupGoogleMock({ handle: HANDLE });
let channel: Awaited<ReturnType<typeof connectMessagingAccount>>;
beforeAll(async () => {
channel = await connectMessagingAccount({
provider: ConnectedAccountProvider.GOOGLE,
handle: HANDLE,
});
}, 60000);
afterAll(async () => {
await channel?.cleanup().catch(() => undefined);
});
it('imports a new event in a single job without a separate import run', async () => {
const eventTitle = `Webhook event ${randomUUID()}`;
google.serveCalendarEvents([googleCalendarEvent({ summary: eventTitle })], {
nextSyncToken: `webhook-sync-token-${randomUUID()}`,
});
await runCalendarChannelWebhookSync(channel.calendarChannelId);
expect(await findImportedCalendarEventTitles([eventTitle])).toEqual([
eventTitle,
]);
const channelState = await queryCalendarChannel(channel);
expect(channelState.syncStage).toBe(
CalendarChannelSyncStage.CALENDAR_EVENT_LIST_FETCH_PENDING,
);
}, 60000);
it('applies an update to an already imported event', async () => {
const eventExternalId = `google-calendar-event-${randomUUID()}`;
const originalTitle = `Webhook event ${randomUUID()}`;
const updatedTitle = `Webhook event updated ${randomUUID()}`;
google.serveCalendarEvents(
[googleCalendarEvent({ id: eventExternalId, summary: originalTitle })],
{ nextSyncToken: `webhook-sync-token-${randomUUID()}` },
);
await runCalendarChannelWebhookSync(channel.calendarChannelId);
expect(await findImportedCalendarEventTitles([originalTitle])).toEqual([
originalTitle,
]);
google.serveCalendarEvents(
[googleCalendarEvent({ id: eventExternalId, summary: updatedTitle })],
{ nextSyncToken: `webhook-sync-token-${randomUUID()}` },
);
await runCalendarChannelWebhookSync(channel.calendarChannelId);
expect(await findImportedCalendarEventTitles([updatedTitle])).toEqual([
updatedTitle,
]);
expect(await findImportedCalendarEventTitles([originalTitle])).toEqual([]);
}, 60000);
it('leaves a delta larger than the inline cap to the import cron', async () => {
const eventTitles = Array.from(
{ length: CALENDAR_EVENT_WEBHOOK_SYNC_INLINE_IMPORT_MAX_EVENTS + 1 },
() => `Webhook bulk event ${randomUUID()}`,
);
google.serveCalendarEvents(
eventTitles.map((summary) => googleCalendarEvent({ summary })),
{ nextSyncToken: `webhook-sync-token-${randomUUID()}` },
);
await runCalendarChannelWebhookSync(channel.calendarChannelId);
expect(await findImportedCalendarEventTitles(eventTitles)).toEqual([]);
const channelState = await queryCalendarChannel(channel);
expect(channelState.syncStage).toBe(
CalendarChannelSyncStage.CALENDAR_EVENTS_IMPORT_PENDING,
);
await runCalendarChannelEventsImport(channel.calendarChannelId);
expect(await findImportedCalendarEventTitles(eventTitles)).toEqual(
[...eventTitles].sort(),
);
}, 120000);
it('does not sync when the channel is already being synced', async () => {
const eventTitle = `Webhook event ${randomUUID()}`;
google.serveCalendarEvents([googleCalendarEvent({ summary: eventTitle })], {
nextSyncToken: `webhook-sync-token-${randomUUID()}`,
});
await runCalendarChannelWebhookSync(
channel.calendarChannelId,
CalendarChannelSyncStage.CALENDAR_EVENT_LIST_FETCH_ONGOING,
);
expect(await findImportedCalendarEventTitles([eventTitle])).toEqual([]);
}, 60000);
});