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);
});

View File

@@ -0,0 +1,221 @@
import { randomUUID } from 'node:crypto';
import { BlocklistScope, ConnectedAccountProvider } from 'twenty-shared/types';
import { WORKSPACE_MEMBER_DATA_SEED_IDS } from 'src/engine/workspace-manager/dev-seeder/data/constants/workspace-member-data-seeds.constant';
import { createOneOperationFactory } from 'test/integration/graphql/utils/create-one-operation-factory.util';
import { destroyOneOperationFactory } from 'test/integration/graphql/utils/destroy-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 { getGmailMessageSubject } from 'test/integration/google/mocks/gmail-message-subject.util';
import { gmailMessage } from 'test/integration/google/mocks/gmail-message.util';
import { setupGoogleMock } from 'test/integration/google/mocks/setup-google-mock.util';
import { connectMessagingAccount } from 'test/integration/utils/connect-messaging-account.util';
import {
findImportedCalendarEventTitles,
findImportedMessageSubjects,
} 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';
import { resetMessageChannelSyncState } from 'test/integration/utils/reset-channel-sync-state.util';
import { runMessageChannelSync } from 'test/integration/utils/run-message-channel-sync.util';
import { waitForAllJobsToFinish } from 'test/integration/utils/wait-for-all-jobs-to-finish.util';
const HANDLE = 'gmail-blocklist-cleanup@apple.dev';
const BLOCKED_HANDLE = `blocked-${randomUUID()}@acme.com`;
const WORKSPACE_BLOCKED_HANDLE = `workspace-blocked-${randomUUID()}@acme.com`;
describe('Blocklist cleanup (integration)', () => {
const blockedMessage = gmailMessage({ from: BLOCKED_HANDLE, to: HANDLE });
const workspaceBlockedMessage = gmailMessage({
from: WORKSPACE_BLOCKED_HANDLE,
to: HANDLE,
});
const keptMessage = gmailMessage({
from: `kept-${randomUUID()}@acme.com`,
to: HANDLE,
});
const blockedSubject = getGmailMessageSubject(blockedMessage);
const workspaceBlockedSubject = getGmailMessageSubject(
workspaceBlockedMessage,
);
const keptSubject = getGmailMessageSubject(keptMessage);
const blockedEventTitle = `Blocked event ${randomUUID()}`;
const keptEventTitle = `Kept event ${randomUUID()}`;
const inbox = [blockedMessage, workspaceBlockedMessage, keptMessage];
const gmail = setupGoogleMock({ handle: HANDLE, inbox });
const blocklistIds: string[] = [];
let channel: Awaited<ReturnType<typeof connectMessagingAccount>>;
beforeAll(async () => {
channel = await connectMessagingAccount({
provider: ConnectedAccountProvider.GOOGLE,
handle: HANDLE,
});
await runMessageChannelSync(channel.channelId);
gmail.serveCalendarEvents([
googleCalendarEvent({
summary: blockedEventTitle,
attendees: [{ email: BLOCKED_HANDLE }],
}),
googleCalendarEvent({
summary: keptEventTitle,
attendees: [{ email: `kept-attendee-${randomUUID()}@acme.com` }],
}),
]);
await runCalendarChannelListFetch(channel.calendarChannelId);
await runCalendarChannelEventsImport(channel.calendarChannelId);
}, 180000);
afterAll(async () => {
for (const blocklistId of blocklistIds) {
await makeGraphqlAPIRequest(
destroyOneOperationFactory({
objectMetadataSingularName: 'blocklist',
gqlFields: 'id',
recordId: blocklistId,
}),
).catch(() => undefined);
}
await channel?.cleanup().catch(() => undefined);
});
it('imports every message and the calendar event before anything is blocked', async () => {
expect(
await findImportedMessageSubjects([
blockedSubject,
workspaceBlockedSubject,
keptSubject,
]),
).toEqual([blockedSubject, workspaceBlockedSubject, keptSubject].sort());
expect(
await findImportedCalendarEventTitles([
blockedEventTitle,
keptEventTitle,
]),
).toEqual([blockedEventTitle, keptEventTitle].sort());
}, 60000);
it('deletes the blocked handle messages and keeps the calendar events imported before the block', async () => {
const response = await makeGraphqlAPIRequest(
createOneOperationFactory({
objectMetadataSingularName: 'blocklist',
gqlFields: 'id handle',
data: {
handle: BLOCKED_HANDLE,
workspaceMemberId: WORKSPACE_MEMBER_DATA_SEED_IDS.JANE,
},
}),
);
expect(response.body.errors).toBeUndefined();
blocklistIds.push(response.body.data.createBlocklist.id);
await waitForAllJobsToFinish();
expect(
await findImportedMessageSubjects([blockedSubject, keptSubject]),
).toEqual([keptSubject]);
expect(
await findImportedCalendarEventTitles([
blockedEventTitle,
keptEventTitle,
]),
).toEqual([blockedEventTitle, keptEventTitle].sort());
}, 120000);
it('deletes the messages of a handle blocked for the whole workspace', async () => {
const response = await makeGraphqlAPIRequest(
createOneOperationFactory({
objectMetadataSingularName: 'blocklist',
gqlFields: 'id handle scope workspaceMemberId',
data: {
handle: WORKSPACE_BLOCKED_HANDLE,
scope: BlocklistScope.WORKSPACE,
},
}),
);
expect(response.body.errors).toBeUndefined();
expect(response.body.data.createBlocklist.workspaceMemberId).toBeNull();
blocklistIds.push(response.body.data.createBlocklist.id);
await waitForAllJobsToFinish();
expect(
await findImportedMessageSubjects([workspaceBlockedSubject, keptSubject]),
).toEqual([keptSubject]);
}, 120000);
it('never imports new messages or calendar events involving a workspace-blocked handle', async () => {
const resyncBlockedMessage = gmailMessage({
from: WORKSPACE_BLOCKED_HANDLE,
to: HANDLE,
});
const resyncKeptMessage = gmailMessage({
from: `resync-kept-${randomUUID()}@acme.com`,
to: HANDLE,
});
const resyncBlockedSubject = getGmailMessageSubject(resyncBlockedMessage);
const resyncKeptSubject = getGmailMessageSubject(resyncKeptMessage);
inbox.push(resyncBlockedMessage, resyncKeptMessage);
gmail.serveMessageList([resyncBlockedMessage, resyncKeptMessage]);
await resetMessageChannelSyncState(channel.channelId, '');
await runMessageChannelSync(channel.channelId);
await waitForAllJobsToFinish();
expect(
await findImportedMessageSubjects([
resyncBlockedSubject,
resyncKeptSubject,
]),
).toEqual([resyncKeptSubject]);
const blockedAttendeeEventTitle = `Workspace blocked event ${randomUUID()}`;
const importedEventTitle = `Workspace kept event ${randomUUID()}`;
gmail.serveCalendarEvents(
[
googleCalendarEvent({
summary: blockedAttendeeEventTitle,
attendees: [{ email: WORKSPACE_BLOCKED_HANDLE }],
}),
googleCalendarEvent({
summary: importedEventTitle,
attendees: [
{ email: `workspace-kept-attendee-${randomUUID()}@acme.com` },
],
}),
],
{ nextSyncToken: 'mock-calendar-sync-token-workspace-blocklist' },
);
await runCalendarChannelListFetch(channel.calendarChannelId);
await runCalendarChannelEventsImport(channel.calendarChannelId);
await waitForAllJobsToFinish();
expect(
await findImportedCalendarEventTitles([
blockedAttendeeEventTitle,
importedEventTitle,
]),
).toEqual([importedEventTitle]);
}, 180000);
});

View File

@@ -0,0 +1,94 @@
import { randomUUID } from 'node:crypto';
import { ConnectedAccountProvider } from 'twenty-shared/types';
import { MessageQueue } from 'src/engine/core-modules/message-queue/message-queue.constants';
import { MessageChannelEntity } from 'src/engine/metadata-modules/message-channel/entities/message-channel.entity';
import {
CalendarChannelDeletionCleanupJob,
type CalendarChannelDeletionCleanupJobData,
} from 'src/modules/calendar/calendar-event-cleaner/jobs/calendar-channel-deletion-cleanup.job';
import {
MessagingMessageChannelDeletionCleanupJob,
type MessagingMessageChannelDeletionCleanupJobData,
} from 'src/modules/messaging/message-cleaner/jobs/messaging-message-channel-deletion-cleanup.job';
import { googleCalendarEvent } from 'test/integration/google/mocks/google-calendar-event.util';
import { getGmailMessageSubject } from 'test/integration/google/mocks/gmail-message-subject.util';
import { gmailMessage } from 'test/integration/google/mocks/gmail-message.util';
import { setupGoogleMock } from 'test/integration/google/mocks/setup-google-mock.util';
import { connectMessagingAccount } from 'test/integration/utils/connect-messaging-account.util';
import { enqueueJobAndDrain } from 'test/integration/utils/enqueue-job-and-drain.util';
import {
findImportedCalendarEventTitles,
findImportedMessageSubjects,
} from 'test/integration/utils/find-imported-records.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';
import { runMessageChannelSync } from 'test/integration/utils/run-message-channel-sync.util';
const HANDLE = 'gmail-channel-deletion-cleanup@apple.dev';
describe('Channel deletion cleanup (integration)', () => {
const inbox = [gmailMessage()];
const expectedSubject = getGmailMessageSubject(inbox[0]);
const eventTitle = `Calendar event ${randomUUID()}`;
const gmail = setupGoogleMock({ handle: HANDLE, inbox });
let channel: Awaited<ReturnType<typeof connectMessagingAccount>>;
let workspaceId: string;
beforeAll(async () => {
channel = await connectMessagingAccount({
provider: ConnectedAccountProvider.GOOGLE,
handle: HANDLE,
});
workspaceId = (
await getCoreRepository<MessageChannelEntity>(
MessageChannelEntity,
).findOneByOrFail({ id: channel.channelId })
).workspaceId;
await runMessageChannelSync(channel.channelId);
gmail.serveCalendarEvents([googleCalendarEvent({ summary: eventTitle })]);
await runCalendarChannelListFetch(channel.calendarChannelId);
await runCalendarChannelEventsImport(channel.calendarChannelId);
}, 180000);
afterAll(async () => {
await channel?.cleanup().catch(() => undefined);
});
it('drops the imported messages when the message channel is cleaned up', async () => {
expect(await findImportedMessageSubjects([expectedSubject])).toEqual([
expectedSubject,
]);
await enqueueJobAndDrain<MessagingMessageChannelDeletionCleanupJobData>(
MessageQueue.messagingQueue,
MessagingMessageChannelDeletionCleanupJob.name,
{ workspaceId, messageChannelId: channel.channelId },
);
expect(await findImportedMessageSubjects([expectedSubject])).toEqual([]);
}, 120000);
it('drops the imported calendar events when the calendar channel is cleaned up', async () => {
expect(await findImportedCalendarEventTitles([eventTitle])).toEqual([
eventTitle,
]);
await enqueueJobAndDrain<CalendarChannelDeletionCleanupJobData>(
MessageQueue.calendarQueue,
CalendarChannelDeletionCleanupJob.name,
{ workspaceId, calendarChannelId: channel.calendarChannelId },
);
expect(await findImportedCalendarEventTitles([eventTitle])).toEqual([]);
}, 120000);
});

View File

@@ -0,0 +1,88 @@
import {
ConnectedAccountProvider,
MessageChannelSyncStage,
MessageChannelSyncStatus,
} from 'twenty-shared/types';
import { ConnectedAccountEntity } from 'src/engine/metadata-modules/connected-account/entities/connected-account.entity';
import { gmailMessage } from 'test/integration/google/mocks/gmail-message.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 { queryMessageChannel } from 'test/integration/utils/query-messaging.util';
import { resetMessageChannelSyncState } from 'test/integration/utils/reset-channel-sync-state.util';
import { runMessageChannelSync } from 'test/integration/utils/run-message-channel-sync.util';
const HANDLE = 'gmail-channel-misconfiguration@apple.dev';
describe('Gmail channel misconfiguration (integration)', () => {
// A full sync has no cursor to store when the first message carries no
// historyId.
const inbox = [gmailMessage({ historyId: undefined })];
setupGoogleMock({ handle: HANDLE, inbox });
const connectedAccountRepository = getCoreRepository<ConnectedAccountEntity>(
ConnectedAccountEntity,
);
let channel: Awaited<ReturnType<typeof connectMessagingAccount>>;
let handleAliases: string[] | null;
beforeAll(async () => {
channel = await connectMessagingAccount({
provider: ConnectedAccountProvider.GOOGLE,
handle: HANDLE,
});
handleAliases = (
await connectedAccountRepository.findOneByOrFail({
id: channel.connectedAccountId,
})
).handleAliases;
}, 60000);
afterAll(async () => {
await connectedAccountRepository
.update({ id: channel?.connectedAccountId }, { handleAliases })
.catch(() => undefined);
await channel?.cleanup().catch(() => undefined);
});
beforeEach(async () => {
await resetMessageChannelSyncState(channel.channelId, '');
});
it('fails the channel when the connected account has no handle aliases', async () => {
await connectedAccountRepository.update(
{ id: channel.connectedAccountId },
{ handleAliases: null },
);
await runMessageChannelSync(channel.channelId);
const channelState = await queryMessageChannel(channel);
expect(channelState.syncStatus).toBe(
MessageChannelSyncStatus.FAILED_UNKNOWN,
);
expect(channelState.syncStage).toBe(MessageChannelSyncStage.FAILED);
}, 60000);
it('fails the channel when the full sync yields no next sync cursor', async () => {
await connectedAccountRepository.update(
{ id: channel.connectedAccountId },
{ handleAliases },
);
await runMessageChannelSync(channel.channelId);
const channelState = await queryMessageChannel(channel);
expect(channelState.syncStatus).toBe(
MessageChannelSyncStatus.FAILED_UNKNOWN,
);
expect(channelState.syncStage).toBe(MessageChannelSyncStage.FAILED);
}, 60000);
});

View File

@@ -0,0 +1,109 @@
import {
ConnectedAccountProvider,
MessageChannelVisibility,
} from 'twenty-shared/types';
import { FIELD_RESTRICTED_ADDITIONAL_PERMISSIONS_REQUIRED } from 'twenty-shared/constants';
import { MessageChannelEntity } from 'src/engine/metadata-modules/message-channel/entities/message-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 { getGmailMessageSubject } from 'test/integration/google/mocks/gmail-message-subject.util';
import { gmailMessage } from 'test/integration/google/mocks/gmail-message.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 { runMessageChannelSync } from 'test/integration/utils/run-message-channel-sync.util';
const HANDLE = 'gmail-channel-visibility@apple.dev';
const RESTRICTED = FIELD_RESTRICTED_ADDITIONAL_PERMISSIONS_REQUIRED;
describe('Message channel visibility (integration)', () => {
const inbox = [gmailMessage()];
const subject = getGmailMessageSubject(inbox[0]);
setupGoogleMock({ handle: HANDLE, inbox });
let channel: Awaited<ReturnType<typeof connectMessagingAccount>>;
const messageQuery = () =>
findManyOperationFactory({
objectMetadataSingularName: 'message',
objectMetadataPluralName: 'messages',
gqlFields: 'subject text',
filter: { subject: { eq: subject } },
});
const readMessageAs = async (
makeRequest:
| typeof makeGraphqlAPIRequest
| typeof makeGraphqlAPIRequestWithMemberRole,
) => {
const response = await makeRequest(messageQuery());
expect(response.body.errors).toBeUndefined();
return response.body.data.messages.edges.map(
(edge: { node: { subject: string; text: string } }) => edge.node,
);
};
const setVisibility = async (visibility: MessageChannelVisibility) => {
await getCoreRepository<MessageChannelEntity>(MessageChannelEntity).update(
{ id: channel.channelId },
{ visibility },
);
};
beforeAll(async () => {
channel = await connectMessagingAccount({
provider: ConnectedAccountProvider.GOOGLE,
handle: HANDLE,
});
await runMessageChannelSync(channel.channelId);
}, 120000);
afterAll(async () => {
await channel?.cleanup().catch(() => undefined);
});
it('shows the full message to another member when the channel shares everything', async () => {
await setVisibility(MessageChannelVisibility.SHARE_EVERYTHING);
const [message] = await readMessageAs(makeGraphqlAPIRequestWithMemberRole);
expect(message.subject).toBe(subject);
expect(message.text).not.toBe(RESTRICTED);
}, 60000);
it('masks the body but keeps the subject for another member under subject visibility', async () => {
await setVisibility(MessageChannelVisibility.SUBJECT);
const [message] = await readMessageAs(makeGraphqlAPIRequestWithMemberRole);
expect(message.subject).toBe(subject);
expect(message.text).toBe(RESTRICTED);
}, 60000);
it('masks both the subject and the body for another member under metadata visibility', async () => {
await setVisibility(MessageChannelVisibility.METADATA);
const [message] = await readMessageAs(makeGraphqlAPIRequestWithMemberRole);
expect(message.subject).toBe(RESTRICTED);
expect(message.text).toBe(RESTRICTED);
}, 60000);
it('always shows the full message to the owner of the connected account', async () => {
await setVisibility(MessageChannelVisibility.METADATA);
const [message] = await readMessageAs(makeGraphqlAPIRequest);
expect(message.subject).toBe(subject);
expect(message.text).not.toBe(RESTRICTED);
}, 60000);
});

View File

@@ -0,0 +1,211 @@
import { randomUUID } from 'node:crypto';
import { type gmail_v1 } from 'googleapis';
import {
ConnectedAccountProvider,
MessageChannelContactAutoCreationPolicy,
} from 'twenty-shared/types';
import { MessageChannelEntity } from 'src/engine/metadata-modules/message-channel/entities/message-channel.entity';
import { gmailMessage } from 'test/integration/google/mocks/gmail-message.util';
import { setupGoogleMock } from 'test/integration/google/mocks/setup-google-mock.util';
import { createManyOperationFactory } from 'test/integration/graphql/utils/create-many-operation-factory.util';
import { deleteOneOperationFactory } from 'test/integration/graphql/utils/delete-one-operation-factory.util';
import { makeGraphqlAPIRequest } from 'test/integration/graphql/utils/make-graphql-api-request.util';
import { mergeManyOperationFactory } from 'test/integration/graphql/utils/merge-many-operation-factory.util';
import { updateOneOperationFactory } from 'test/integration/graphql/utils/update-one-operation-factory.util';
import { connectMessagingAccount } from 'test/integration/utils/connect-messaging-account.util';
import { deleteRecordsByIds } from 'test/integration/utils/delete-records-by-ids';
import { findRecordNodesByFilter } from 'test/integration/utils/find-records-by-filter.util';
import { getCoreRepository } from 'test/integration/utils/get-core-repository.util';
import { resetMessageChannelSyncState } from 'test/integration/utils/reset-channel-sync-state.util';
import { runMessageChannelSync } from 'test/integration/utils/run-message-channel-sync.util';
import { waitForTimelineActivities } from 'test/integration/utils/wait-for-timeline-activities.util';
const HANDLE = 'gmail-company-domain-matching@apple.dev';
describe('Gmail contact auto-creation company domain matching (integration)', () => {
const inbox: gmail_v1.Schema$Message[] = [];
const gmail = setupGoogleMock({ handle: HANDLE, inbox });
let channel: Awaited<ReturnType<typeof connectMessagingAccount>>;
const createdCompanyIds: string[] = [];
const createCompany = async (primaryLinkUrl: string) => {
const createResponse = await makeGraphqlAPIRequest(
createManyOperationFactory({
objectMetadataSingularName: 'company',
objectMetadataPluralName: 'companies',
gqlFields: 'id domainName { primaryLinkUrl }',
data: [
{
name: `Company ${randomUUID()}`,
domainName: { primaryLinkUrl },
},
],
}),
);
const [company] = createResponse.body.data.createCompanies;
createdCompanyIds.push(company.id);
return company;
};
const mergeTwoCompanies = async ({
survivingDomainName,
mergedAwayDomainName,
}: {
survivingDomainName: string;
mergedAwayDomainName: string;
}) => {
const survivingCompany = await createCompany(
`https://${survivingDomainName}`,
);
const mergedAwayCompany = await createCompany(
`https://www.${mergedAwayDomainName}`,
);
await waitForTimelineActivities('targetCompanyId', [
survivingCompany.id,
mergedAwayCompany.id,
]);
const mergeResponse = await makeGraphqlAPIRequest(
mergeManyOperationFactory({
objectMetadataPluralName: 'companies',
gqlFields: 'id domainName { primaryLinkUrl secondaryLinks }',
ids: [survivingCompany.id, mergedAwayCompany.id],
conflictPriorityIndex: 0,
}),
);
expect(mergeResponse.body.errors).toBeUndefined();
return mergeResponse.body.data.mergeCompanies;
};
const syncMessageFrom = async (sender: string) => {
const messages = [gmailMessage({ from: sender, to: HANDLE })];
inbox.push(...messages);
gmail.serveMessageList(messages);
await resetMessageChannelSyncState(channel.channelId, '');
await runMessageChannelSync(channel.channelId);
const [person] = await findRecordNodesByFilter<{
company: { id: string } | null;
}>('person', 'people', 'company { id }', {
emails: { primaryEmail: { eq: sender } },
});
return person?.company?.id;
};
beforeAll(async () => {
channel = await connectMessagingAccount({
provider: ConnectedAccountProvider.GOOGLE,
handle: HANDLE,
});
await getCoreRepository<MessageChannelEntity>(MessageChannelEntity).update(
{ id: channel.channelId },
{
isContactAutoCreationEnabled: true,
contactAutoCreationPolicy:
MessageChannelContactAutoCreationPolicy.SENT_AND_RECEIVED,
excludeGroupEmails: false,
excludeNonProfessionalEmails: false,
},
);
}, 60000);
afterAll(async () => {
await deleteRecordsByIds('company', createdCompanyIds);
await channel?.cleanup().catch(() => undefined);
});
it('stores a domain name as its bare domain whatever spelling it was written with', async () => {
const domainName = `written-as-url-${randomUUID()}.com`;
const company = await createCompany(
`HTTPS://WWW.${domainName.toUpperCase()}/careers?utm=1`,
);
expect(company.domainName.primaryLinkUrl).toBe(domainName);
}, 120000);
it('attaches a contact to a company whose domain was written as a full url', async () => {
const domainName = `full-url-${randomUUID()}.com`;
const company = await createCompany(`https://www.${domainName}/careers`);
expect(await syncMessageFrom(`sender@${domainName}`)).toBe(company.id);
}, 120000);
it('attaches a contact to the surviving company when its domain was merged into secondary links', async () => {
const mergedAwayDomainName = `merged-away-${randomUUID()}.com`;
const mergedCompany = await mergeTwoCompanies({
survivingDomainName: `surviving-${randomUUID()}.com`,
mergedAwayDomainName,
});
expect(mergedCompany.domainName.secondaryLinks).toEqual([
expect.objectContaining({ url: mergedAwayDomainName }),
]);
expect(await syncMessageFrom(`sender@${mergedAwayDomainName}`)).toBe(
mergedCompany.id,
);
}, 120000);
it('prefers the live company holding the domain as a secondary link over a deleted one holding it as its primary', async () => {
const domainName = `contested-${randomUUID()}.com`;
const deletedCompany = await createCompany(domainName);
await makeGraphqlAPIRequest(
deleteOneOperationFactory({
objectMetadataSingularName: 'company',
gqlFields: 'id',
recordId: deletedCompany.id,
}),
);
const liveCompany = await createCompany(`live-${randomUUID()}.com`);
await makeGraphqlAPIRequest(
updateOneOperationFactory({
objectMetadataSingularName: 'company',
gqlFields: 'id domainName { secondaryLinks }',
recordId: liveCompany.id,
data: {
domainName: {
primaryLinkUrl: liveCompany.domainName.primaryLinkUrl,
secondaryLinks: [{ url: domainName, label: '' }],
},
},
}),
);
expect(await syncMessageFrom(`sender@${domainName}`)).toBe(liveCompany.id);
}, 120000);
it('attaches a contact to the surviving company on its primary domain', async () => {
const survivingDomainName = `surviving-${randomUUID()}.com`;
const mergedCompany = await mergeTwoCompanies({
survivingDomainName,
mergedAwayDomainName: `merged-away-${randomUUID()}.com`,
});
expect(await syncMessageFrom(`sender@${survivingDomainName}`)).toBe(
mergedCompany.id,
);
}, 120000);
});

View File

@@ -0,0 +1,120 @@
import { ConnectedAccountProvider } from 'twenty-shared/types';
import { isDefined } from 'twenty-shared/utils';
import { gmailMessage } from 'test/integration/google/mocks/gmail-message.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 { runMessageChannelSync } from 'test/integration/utils/run-message-channel-sync.util';
const HANDLE = 'messaging-cleanup@apple.dev';
describe('Messaging connected account cleanup (integration)', () => {
const inbox = [gmailMessage(), gmailMessage()];
setupGoogleMock({ handle: HANDLE, inbox });
let channel: Awaited<ReturnType<typeof connectMessagingAccount>>;
beforeAll(async () => {
channel = await connectMessagingAccount({
provider: ConnectedAccountProvider.GOOGLE,
handle: HANDLE,
});
await runMessageChannelSync(channel.channelId);
}, 60000);
afterAll(async () => {
await channel?.cleanup().catch(() => undefined);
});
it('deletes all associated messaging data when the connected account is removed', async () => {
const associations = await findRecordNodesByFilter<{
id: string;
messageId: string;
}>(
'messageChannelMessageAssociation',
'messageChannelMessageAssociations',
`id
messageId`,
{ messageChannelId: { eq: channel.channelId } },
);
expect(associations).toHaveLength(inbox.length);
const associationIds = associations.map((association) => association.id);
const messageIds = associations.map((association) => association.messageId);
const messages = await findRecordNodesByFilter<{
id: string;
messageThreadId: string | null;
}>(
'message',
'messages',
`id
messageThreadId`,
{ id: { in: messageIds } },
);
expect(messages).toHaveLength(inbox.length);
const threadIds = [
...new Set(messages.map((message) => message.messageThreadId)),
].filter(isDefined);
expect(
await findRecordIdsByFilter(
'messageChannelMessageAssociationMessageFolder',
'messageChannelMessageAssociationMessageFolders',
{ messageChannelMessageAssociationId: { in: associationIds } },
),
).not.toHaveLength(0);
expect(
await findRecordIdsByFilter('messageParticipant', 'messageParticipants', {
messageId: { in: messageIds },
}),
).not.toHaveLength(0);
expect(
await findRecordIdsByFilter('messageThread', 'messageThreads', {
id: { in: threadIds },
}),
).not.toHaveLength(0);
await deleteConnectedAccount(channel.connectedAccountId);
expect(
await findRecordIdsByFilter('message', 'messages', {
id: { in: messageIds },
}),
).toHaveLength(0);
expect(
await findRecordIdsByFilter(
'messageChannelMessageAssociation',
'messageChannelMessageAssociations',
{ messageChannelId: { eq: channel.channelId } },
),
).toHaveLength(0);
expect(
await findRecordIdsByFilter(
'messageChannelMessageAssociationMessageFolder',
'messageChannelMessageAssociationMessageFolders',
{ messageChannelMessageAssociationId: { in: associationIds } },
),
).toHaveLength(0);
expect(
await findRecordIdsByFilter('messageParticipant', 'messageParticipants', {
messageId: { in: messageIds },
}),
).toHaveLength(0);
expect(
await findRecordIdsByFilter('messageThread', 'messageThreads', {
id: { in: threadIds },
}),
).toHaveLength(0);
}, 60000);
});

View File

@@ -0,0 +1,185 @@
import { randomUUID } from 'node:crypto';
import { type gmail_v1 } from 'googleapis';
import {
ConnectedAccountProvider,
MessageChannelContactAutoCreationPolicy,
} from 'twenty-shared/types';
import { MessageChannelEntity } from 'src/engine/metadata-modules/message-channel/entities/message-channel.entity';
import { gmailMessage } from 'test/integration/google/mocks/gmail-message.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 { getCoreRepository } from 'test/integration/utils/get-core-repository.util';
import { resetMessageChannelSyncState } from 'test/integration/utils/reset-channel-sync-state.util';
import { runMessageChannelSync } from 'test/integration/utils/run-message-channel-sync.util';
const HANDLE = 'gmail-contact-policy@apple.dev';
describe('Gmail contact auto-creation policy (integration)', () => {
const inbox: gmail_v1.Schema$Message[] = [];
const gmail = setupGoogleMock({ handle: HANDLE, inbox });
let channel: Awaited<ReturnType<typeof connectMessagingAccount>>;
const syncConversationWith = async ({
inboundSender,
outboundRecipient,
channelSettings,
}: {
inboundSender: string;
outboundRecipient: string;
channelSettings: Pick<
MessageChannelEntity,
| 'contactAutoCreationPolicy'
| 'excludeGroupEmails'
| 'excludeNonProfessionalEmails'
> &
Partial<Pick<MessageChannelEntity, 'isContactAutoCreationEnabled'>>;
}) => {
const messages = [
gmailMessage({ from: inboundSender, to: HANDLE }),
gmailMessage({ from: HANDLE, to: outboundRecipient }),
];
inbox.push(...messages);
gmail.serveMessageList(messages);
await getCoreRepository<MessageChannelEntity>(MessageChannelEntity).update(
{ id: channel.channelId },
channelSettings,
);
await resetMessageChannelSyncState(channel.channelId, '');
await runMessageChannelSync(channel.channelId);
return findCreatedPeopleEmails([inboundSender, outboundRecipient]);
};
beforeAll(async () => {
channel = await connectMessagingAccount({
provider: ConnectedAccountProvider.GOOGLE,
handle: HANDLE,
});
}, 60000);
afterAll(async () => {
await channel?.cleanup().catch(() => undefined);
});
it('creates no contact when the policy is NONE', async () => {
const inboundSender = `inbound-none-${randomUUID()}@acme.com`;
const outboundRecipient = `outbound-none-${randomUUID()}@acme.com`;
expect(
await syncConversationWith({
inboundSender,
outboundRecipient,
channelSettings: {
contactAutoCreationPolicy:
MessageChannelContactAutoCreationPolicy.NONE,
excludeGroupEmails: false,
excludeNonProfessionalEmails: false,
},
}),
).toEqual([]);
}, 60000);
it('creates only the recipient of a sent message when the policy is SENT', async () => {
const inboundSender = `inbound-sent-${randomUUID()}@acme.com`;
const outboundRecipient = `outbound-sent-${randomUUID()}@acme.com`;
expect(
await syncConversationWith({
inboundSender,
outboundRecipient,
channelSettings: {
contactAutoCreationPolicy:
MessageChannelContactAutoCreationPolicy.SENT,
excludeGroupEmails: false,
excludeNonProfessionalEmails: false,
},
}),
).toEqual([outboundRecipient]);
}, 60000);
it('creates both sides when the policy is SENT_AND_RECEIVED', async () => {
const inboundSender = `inbound-both-${randomUUID()}@acme.com`;
const outboundRecipient = `outbound-both-${randomUUID()}@acme.com`;
expect(
await syncConversationWith({
inboundSender,
outboundRecipient,
channelSettings: {
contactAutoCreationPolicy:
MessageChannelContactAutoCreationPolicy.SENT_AND_RECEIVED,
excludeGroupEmails: false,
excludeNonProfessionalEmails: false,
},
}),
).toEqual([inboundSender, outboundRecipient].sort());
}, 60000);
it('creates no contact when auto-creation is disabled on the channel', async () => {
const inboundSender = `inbound-disabled-${randomUUID()}@acme.com`;
const outboundRecipient = `outbound-disabled-${randomUUID()}@acme.com`;
expect(
await syncConversationWith({
inboundSender,
outboundRecipient,
channelSettings: {
isContactAutoCreationEnabled: false,
contactAutoCreationPolicy:
MessageChannelContactAutoCreationPolicy.SENT_AND_RECEIVED,
excludeGroupEmails: false,
excludeNonProfessionalEmails: false,
},
}),
).toEqual([]);
}, 60000);
it('skips group email addresses when they are excluded', async () => {
const inboundSender = `contact@acme-${randomUUID()}.com`;
const outboundRecipient = `outbound-group-${randomUUID()}@acme.com`;
expect(
await syncConversationWith({
inboundSender,
outboundRecipient,
channelSettings: {
isContactAutoCreationEnabled: true,
contactAutoCreationPolicy:
MessageChannelContactAutoCreationPolicy.SENT_AND_RECEIVED,
excludeGroupEmails: true,
excludeNonProfessionalEmails: false,
},
}),
).toEqual([outboundRecipient]);
}, 60000);
it('skips non-professional email addresses when they are excluded', async () => {
const inboundSender = `inbound-personal-${randomUUID()}@gmail.com`;
const outboundRecipient = `outbound-work-${randomUUID()}@acme.com`;
expect(
await syncConversationWith({
inboundSender,
outboundRecipient,
channelSettings: {
isContactAutoCreationEnabled: true,
contactAutoCreationPolicy:
MessageChannelContactAutoCreationPolicy.SENT_AND_RECEIVED,
excludeGroupEmails: false,
excludeNonProfessionalEmails: true,
},
}),
).toEqual([outboundRecipient]);
}, 60000);
});

View File

@@ -0,0 +1,94 @@
import { randomUUID } from 'node:crypto';
import {
ConnectedAccountProvider,
MessageChannelSyncStage,
MessageChannelSyncStatus,
MessageChannelType,
} from 'twenty-shared/types';
import { MessageChannelEntity } from 'src/engine/metadata-modules/message-channel/entities/message-channel.entity';
import { MessagingRelaunchFailedMessageChannelsCronJob } from 'src/modules/messaging/message-import-manager/crons/jobs/messaging-relaunch-failed-message-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 { runSyncCron } from 'test/integration/utils/run-sync-cron.util';
const HANDLE = 'gmail-email-group-exclusion@apple.dev';
describe('Email group channel sync exclusion (integration)', () => {
setupGoogleMock({ handle: HANDLE });
const messageChannelRepository =
getCoreRepository<MessageChannelEntity>(MessageChannelEntity);
let channel: Awaited<ReturnType<typeof connectMessagingAccount>>;
let emailGroupChannelId: string;
const readChannel = (id: string) =>
messageChannelRepository.findOneByOrFail({ id });
const failChannel = (id: string) =>
messageChannelRepository.update(
{ id },
{
syncStatus: MessageChannelSyncStatus.FAILED_UNKNOWN,
syncStage: MessageChannelSyncStage.FAILED,
throttleFailureCount: 3,
},
);
beforeAll(async () => {
channel = await connectMessagingAccount({
provider: ConnectedAccountProvider.GOOGLE,
handle: HANDLE,
});
const emailChannel = await readChannel(channel.channelId);
// Cloned so columns added to the channel later do not break this insert.
const { id: _id, ...emailChannelColumns } = emailChannel;
const emailGroupChannel = await messageChannelRepository.save({
...emailChannelColumns,
handle: `group-${randomUUID()}@apple.dev`,
type: MessageChannelType.EMAIL_GROUP,
isSyncEnabled: true,
});
emailGroupChannelId = emailGroupChannel.id;
}, 60000);
afterAll(async () => {
await messageChannelRepository
.delete({ id: emailGroupChannelId })
.catch(() => undefined);
await channel?.cleanup().catch(() => undefined);
});
it('relaunches a failed email channel but never a failed email group channel', async () => {
await failChannel(channel.channelId);
await failChannel(emailGroupChannelId);
await runSyncCron(MessagingRelaunchFailedMessageChannelsCronJob);
const relaunchedChannel = await readChannel(channel.channelId);
expect(relaunchedChannel.syncStatus).toBe(MessageChannelSyncStatus.ACTIVE);
expect(relaunchedChannel.syncStage).toBe(
MessageChannelSyncStage.MESSAGE_LIST_FETCH_PENDING,
);
expect(relaunchedChannel.throttleFailureCount).toBe(0);
const untouchedGroupChannel = await readChannel(emailGroupChannelId);
expect(untouchedGroupChannel.syncStatus).toBe(
MessageChannelSyncStatus.FAILED_UNKNOWN,
);
expect(untouchedGroupChannel.syncStage).toBe(
MessageChannelSyncStage.FAILED,
);
expect(untouchedGroupChannel.throttleFailureCount).toBe(3);
}, 120000);
});

View File

@@ -0,0 +1,79 @@
import {
ConnectedAccountProvider,
MessageFolderImportPolicy,
} from 'twenty-shared/types';
import { setupGoogleMock } from 'test/integration/google/mocks/setup-google-mock.util';
import { connectMessagingAccount } from 'test/integration/utils/connect-messaging-account.util';
import {
queryMessageFolders,
updateMessageChannel,
} from 'test/integration/utils/query-messaging.util';
import { runMessageChannelSync } from 'test/integration/utils/run-message-channel-sync.util';
import { startChannelSyncAndAwait } from 'test/integration/utils/start-channel-sync-and-await.util';
const HANDLE = 'gmail-folder-discovery@apple.dev';
describe('Gmail folder discovery (integration)', () => {
const gmail = setupGoogleMock({
handle: HANDLE,
labels: [
{ id: 'INBOX', name: 'INBOX' },
{ id: 'SENT', name: 'SENT' },
{ id: 'Label_Work', name: 'Work' },
],
});
let channel: Awaited<ReturnType<typeof connectMessagingAccount>>;
beforeAll(async () => {
channel = await connectMessagingAccount({
provider: ConnectedAccountProvider.GOOGLE,
handle: HANDLE,
skipChannelConfiguration: false,
});
}, 60000);
afterAll(async () => {
await channel?.cleanup().catch(() => undefined);
});
it('syncs every folder discovered at connect time under the default all-folders policy', async () => {
await startChannelSyncAndAwait(channel.connectedAccountId);
const folders = await queryMessageFolders(channel.channelId);
expect(
Object.fromEntries(
folders.map((folder) => [folder.name, folder.isSynced]),
),
).toEqual({
INBOX: true,
SENT: true,
Work: true,
});
}, 60000);
it('leaves a folder discovered after switching to selected-folders unsynced', async () => {
await updateMessageChannel(channel.channelId, {
messageFolderImportPolicy: MessageFolderImportPolicy.SELECTED_FOLDERS,
});
gmail.labels.add({ id: 'Label_Archive', name: 'Archive' });
await runMessageChannelSync(channel.channelId);
const folders = await queryMessageFolders(channel.channelId);
expect(
Object.fromEntries(
folders.map((folder) => [folder.name, folder.isSynced]),
),
).toEqual({
INBOX: true,
SENT: true,
Work: true,
Archive: false,
});
}, 60000);
});

View File

@@ -0,0 +1,98 @@
import {
ConnectedAccountProvider,
MessageFolderPendingSyncAction,
} from 'twenty-shared/types';
import { getGmailMessageSubject } from 'test/integration/google/mocks/gmail-message-subject.util';
import { gmailMessage } from 'test/integration/google/mocks/gmail-message.util';
import { setupGoogleMock } from 'test/integration/google/mocks/setup-google-mock.util';
import { connectMessagingAccount } from 'test/integration/utils/connect-messaging-account.util';
import { findImportedMessageSubjects } from 'test/integration/utils/find-imported-records.util';
import {
type MessageFolderDto,
queryMessageFolders,
} from 'test/integration/utils/query-messaging.util';
import {
FOLDER_ACTION_NAMES,
MESSAGE_COUNT_ABOVE_V8_SPREAD_ARGUMENT_LIMIT,
runFolderActions,
runFolderActionsWithoutPendingActions,
} from 'test/integration/utils/run-folder-actions.util';
import { runMessageChannelSync } from 'test/integration/utils/run-message-channel-sync.util';
const HANDLE = 'gmail-folder-actions@apple.dev';
const DELETED_FOLDER_NAME = 'INBOX';
describe('Gmail folder actions (integration)', () => {
const inbox = [gmailMessage()];
const importedSubject = getGmailMessageSubject(inbox[0]);
setupGoogleMock({ handle: HANDLE, inbox });
let channel: Awaited<ReturnType<typeof connectMessagingAccount>>;
let messageExternalIdsToImport: string[];
let messageExternalIdsWithoutPendingActions: string[];
let folders: MessageFolderDto[];
let subjectsBeforeFolderDeletion: string[];
let subjectsAfterFolderDeletion: string[];
beforeAll(async () => {
channel = await connectMessagingAccount({
provider: ConnectedAccountProvider.GOOGLE,
handle: HANDLE,
});
await runMessageChannelSync(channel.channelId);
subjectsBeforeFolderDeletion = await findImportedMessageSubjects([
importedSubject,
]);
messageExternalIdsToImport = await runFolderActions({
messageChannelId: channel.channelId,
folderNameToDelete: DELETED_FOLDER_NAME,
});
subjectsAfterFolderDeletion = await findImportedMessageSubjects([
importedSubject,
]);
folders = await queryMessageFolders(channel.channelId);
messageExternalIdsWithoutPendingActions =
await runFolderActionsWithoutPendingActions(channel.channelId);
}, 180000);
afterAll(async () => {
jest.restoreAllMocks();
await channel?.cleanup().catch(() => undefined);
});
it('imports every message id of a folder larger than the spread-argument limit, deduplicated', () => {
expect(messageExternalIdsToImport).toHaveLength(
MESSAGE_COUNT_ABOVE_V8_SPREAD_ARGUMENT_LIMIT,
);
});
it('clears the pending action it processed, keeps the one it failed on, and drops the deleted folder', () => {
expect(
Object.fromEntries(
folders.map((folder) => [folder.name, folder.pendingSyncAction]),
),
).toEqual({
SENT: MessageFolderPendingSyncAction.NONE,
[FOLDER_ACTION_NAMES.imported]: MessageFolderPendingSyncAction.NONE,
[FOLDER_ACTION_NAMES.failing]:
MessageFolderPendingSyncAction.FOLDER_IMPORT,
[FOLDER_ACTION_NAMES.untouched]: MessageFolderPendingSyncAction.NONE,
});
});
it('deletes the messages of the folder marked for deletion', () => {
expect(subjectsBeforeFolderDeletion).toEqual([importedSubject]);
expect(subjectsAfterFolderDeletion).toEqual([]);
});
it('imports nothing when no folder carries a pending action', () => {
expect(messageExternalIdsWithoutPendingActions).toEqual([]);
});
});

View File

@@ -0,0 +1,224 @@
import {
ConnectedAccountProvider,
MessageChannelSyncStage,
MessageChannelSyncStatus,
} from 'twenty-shared/types';
import { ConnectedAccountEntity } from 'src/engine/metadata-modules/connected-account/entities/connected-account.entity';
import { MessageChannelEntity } from 'src/engine/metadata-modules/message-channel/entities/message-channel.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 {
queryConnectedAccount,
queryMessageChannel,
} from 'test/integration/utils/query-messaging.util';
import { resetMessageChannelSyncState } from 'test/integration/utils/reset-channel-sync-state.util';
import { runMessageChannelSync } from 'test/integration/utils/run-message-channel-sync.util';
const HANDLE = 'gmail-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.',
},
],
[
'a 400 failedPrecondition response for a disabled mail service',
{
status: 400,
reason: 'failedPrecondition',
message: 'Mail service not enabled',
},
],
];
const TEMPORARY_FAILURES: [string, GoogleApiFailure][] = [
[
'a 401 unauthorized response',
{ status: 401, reason: 'authError', message: 'Invalid Credentials' },
],
[
'a 403 insufficientPermissions response',
{
status: 403,
reason: 'insufficientPermissions',
message: 'Request had insufficient authentication scopes.',
},
],
[
'a 403 domainPolicy response',
{
status: 403,
reason: 'domainPolicy',
message: 'The domain policy has disabled third-party Drive apps',
},
],
[
'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 rate limit response',
{
status: 403,
reason: 'rateLimitExceeded',
message: 'Rate Limit Exceeded',
},
],
[
'a 400 failedPrecondition response unrelated to the mail service',
{
status: 400,
reason: 'failedPrecondition',
message: 'Precondition check failed.',
},
],
[
'a 503 service unavailable response',
{
status: 503,
reason: 'backendError',
message: 'The service is currently unavailable.',
},
],
[
'a 500 backendError response',
{ status: 500, reason: 'backendError', message: 'Backend Error' },
],
];
const UNKNOWN_FAILURES: [string, GoogleApiFailure][] = [
[
'a 400 response with an unmapped reason',
{ status: 400, reason: 'invalidArgument', message: 'Invalid query' },
],
];
describe('Gmail import error mapping (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);
});
beforeEach(async () => {
await resetMessageChannelSyncState(channel.channelId);
await getCoreRepository<ConnectedAccountEntity>(
ConnectedAccountEntity,
).update({ id: channel.connectedAccountId }, { authFailedAt: null });
});
it.each(INSUFFICIENT_PERMISSIONS_FAILURES)(
'fails the channel as insufficient-permissions on %s',
async (_label, failure) => {
gmail.failMessageList(failure);
await runMessageChannelSync(channel.channelId);
const channelState = await queryMessageChannel(channel);
expect(channelState.syncStatus).toBe(
MessageChannelSyncStatus.FAILED_INSUFFICIENT_PERMISSIONS,
);
expect(channelState.syncStage).toBe(MessageChannelSyncStage.FAILED);
const account = await queryConnectedAccount(channel.connectedAccountId);
expect(account.authFailedAt).not.toBeNull();
},
60000,
);
it.each(TEMPORARY_FAILURES)(
'throttles the channel without failing it on %s',
async (_label, failure) => {
gmail.failMessageList(failure);
await runMessageChannelSync(channel.channelId);
const channelState = await queryMessageChannel(channel);
expect(channelState.throttleFailureCount).toBe(1);
expect(channelState.syncStage).toBe(
MessageChannelSyncStage.MESSAGE_LIST_FETCH_PENDING,
);
expect(channelState.syncStatus).not.toBe(
MessageChannelSyncStatus.FAILED_UNKNOWN,
);
expect(channelState.syncStatus).not.toBe(
MessageChannelSyncStatus.FAILED_INSUFFICIENT_PERMISSIONS,
);
},
60000,
);
it.each(UNKNOWN_FAILURES)(
'fails the channel as unknown on %s',
async (_label, failure) => {
gmail.failMessageList(failure);
await runMessageChannelSync(channel.channelId);
const channelState = await queryMessageChannel(channel);
expect(channelState.syncStatus).toBe(
MessageChannelSyncStatus.FAILED_UNKNOWN,
);
expect(channelState.syncStage).toBe(MessageChannelSyncStage.FAILED);
expect(channelState.throttleFailureCount).toBe(0);
},
60000,
);
it('restarts the channel from a clean cursor on a 404 sync cursor error', async () => {
gmail.failMessageList({
status: 404,
reason: 'notFound',
message: 'Requested entity was not found.',
});
await runMessageChannelSync(channel.channelId);
const channelState = await queryMessageChannel(channel);
expect(channelState.syncStage).toBe(
MessageChannelSyncStage.MESSAGE_LIST_FETCH_PENDING,
);
expect(channelState.syncStatus).not.toBe(
MessageChannelSyncStatus.FAILED_UNKNOWN,
);
expect(channelState.throttleFailureCount).toBe(0);
expect(channelState.syncStageStartedAt).toBeNull();
const storedChannel = await getCoreRepository<MessageChannelEntity>(
MessageChannelEntity,
).findOneByOrFail({ id: channel.channelId });
expect(storedChannel.syncCursor).toBeFalsy();
}, 60000);
});

View File

@@ -0,0 +1,60 @@
import { ConnectedAccountProvider } from 'twenty-shared/types';
import { getGmailMessageSubject } from 'test/integration/google/mocks/gmail-message-subject.util';
import { gmailMessage } from 'test/integration/google/mocks/gmail-message.util';
import { setupGoogleMock } from 'test/integration/google/mocks/setup-google-mock.util';
import { connectMessagingAccount } from 'test/integration/utils/connect-messaging-account.util';
import { findImportedMessageSubjects } from 'test/integration/utils/find-imported-records.util';
import { runMessageChannelSync } from 'test/integration/utils/run-message-channel-sync.util';
const HANDLE = 'messaging-incremental-sync@apple.dev';
describe('Messaging incremental sync (integration)', () => {
const inbox = [gmailMessage()];
const gmail = setupGoogleMock({ handle: HANDLE, inbox });
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 the initial inbox through a full sync', async () => {
await runMessageChannelSync(channel.channelId);
const initialSubject = getGmailMessageSubject(inbox[0]);
expect(await findImportedMessageSubjects([initialSubject])).toEqual([
initialSubject,
]);
}, 60000);
it('imports a newly arrived message through the history-based incremental sync', async () => {
const initialMessages = [...inbox];
const newMessage = gmailMessage();
inbox.push(newMessage);
// The list endpoint keeps serving only the initial inbox: the new message
// is reachable through the history endpoint alone, so a regression that
// re-runs a full list fetch instead of the history-based sync cannot pass.
gmail.serveMessageList(initialMessages);
gmail.serveHistory([newMessage]);
await runMessageChannelSync(channel.channelId);
const newSubject = getGmailMessageSubject(newMessage);
expect(await findImportedMessageSubjects([newSubject])).toEqual([
newSubject,
]);
}, 60000);
});

View File

@@ -0,0 +1,47 @@
import { ConnectedAccountProvider } from 'twenty-shared/types';
import { getGmailMessageSubject } from 'test/integration/google/mocks/gmail-message-subject.util';
import { gmailMessage } from 'test/integration/google/mocks/gmail-message.util';
import { setupGoogleMock } from 'test/integration/google/mocks/setup-google-mock.util';
import { connectMessagingAccount } from 'test/integration/utils/connect-messaging-account.util';
import { findImportedMessageSubjects } from 'test/integration/utils/find-imported-records.util';
import { queryMessageFolders } from 'test/integration/utils/query-messaging.util';
import { runMessageChannelSync } from 'test/integration/utils/run-message-channel-sync.util';
const HANDLE = 'gmail-message-list-fetch@apple.dev';
describe('Gmail message list fetch (integration)', () => {
const inbox = [gmailMessage(), gmailMessage()];
setupGoogleMock({ handle: HANDLE, inbox });
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('runs the full sync pipeline on the real worker: folders synced, messages imported', async () => {
await runMessageChannelSync(channel.channelId);
const expectedSubjects = inbox.map(getGmailMessageSubject);
expect(await findImportedMessageSubjects(expectedSubjects)).toEqual(
[...expectedSubjects].sort(),
);
const folders = await queryMessageFolders(channel.channelId);
expect(folders.map((folder) => folder.name).sort()).toEqual([
'INBOX',
'SENT',
]);
}, 60000);
});

View File

@@ -0,0 +1,74 @@
import {
ConnectedAccountProvider,
MessageChannelSyncStage,
} from 'twenty-shared/types';
import { getGmailMessageSubject } from 'test/integration/google/mocks/gmail-message-subject.util';
import { gmailMessage } from 'test/integration/google/mocks/gmail-message.util';
import { setupGoogleMock } from 'test/integration/google/mocks/setup-google-mock.util';
import { connectMessagingAccount } from 'test/integration/utils/connect-messaging-account.util';
import { expectEventually } from 'test/integration/utils/expect-eventually.util';
import { findImportedMessageSubjects } from 'test/integration/utils/find-imported-records.util';
import { queryMessageChannels } from 'test/integration/utils/query-messaging.util';
import { runMessageChannelSync } from 'test/integration/utils/run-message-channel-sync.util';
const HANDLE = 'gmail-onboarding-recent-messages-import@apple.dev';
const findChannel = async (channelId: string) => {
const channels = await queryMessageChannels();
return channels.find((channel) => channel.id === channelId);
};
describe('Gmail onboarding recent messages import (integration)', () => {
const sentMessages = [
gmailMessage({ labelIds: ['SENT'] }),
gmailMessage({ labelIds: ['SENT'] }),
];
const expectedSubjects = sentMessages.map(getGmailMessageSubject).sort();
setupGoogleMock({ handle: HANDLE, inbox: sentMessages });
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 the recent sent messages during connect, before any sync job runs', async () => {
await expectEventually(
async () => {
expect(await findImportedMessageSubjects(expectedSubjects)).toEqual(
expectedSubjects,
);
},
{ timeoutMs: 30_000 },
);
}, 60000);
it('leaves the channel awaiting the full message list fetch', async () => {
await expectEventually(
async () => {
expect((await findChannel(channel.channelId))?.syncStage).toEqual(
MessageChannelSyncStage.MESSAGE_LIST_FETCH_PENDING,
);
},
{ timeoutMs: 30_000 },
);
}, 60000);
it('lets the full sync complete afterwards without duplicating the imported messages', async () => {
await runMessageChannelSync(channel.channelId);
expect(await findImportedMessageSubjects(expectedSubjects)).toEqual(
expectedSubjects,
);
}, 60000);
});

View File

@@ -0,0 +1,300 @@
import { randomUUID } from 'node:crypto';
import { ConnectedAccountProvider } from 'twenty-shared/types';
import { ConnectedAccountEntity } from 'src/engine/metadata-modules/connected-account/entities/connected-account.entity';
import { gmailMessage } from 'test/integration/google/mocks/gmail-message.util';
import { setupGoogleMock } from 'test/integration/google/mocks/setup-google-mock.util';
import { connectMessagingAccount } from 'test/integration/utils/connect-messaging-account.util';
import { createCalendarEvent } from 'test/integration/utils/create-calendar-event.util';
import { findImportedCalendarEventTitles } from 'test/integration/utils/find-imported-records.util';
import { findRecordNodesByFilter } from 'test/integration/utils/find-records-by-filter.util';
import { getCoreRepository } from 'test/integration/utils/get-core-repository.util';
import { runMessageChannelSync } from 'test/integration/utils/run-message-channel-sync.util';
import { sendEmail } from 'test/integration/utils/send-email.util';
const HANDLE = 'gmail-outbound@apple.dev';
const ALIAS = 'gmail-outbound-alias@apple.dev';
const RECIPIENTS = {
to: 'to-recipient@example.com',
cc: 'cc-recipient@example.com',
bcc: 'bcc-recipient@example.com',
};
const PARENT_MESSAGE = gmailMessage({
id: 'gmail-parent',
threadId: 'gmail-parent-thread',
to: HANDLE,
});
const DRAFT_SUBJECT = `Gmail draft ${randomUUID()}`;
const DRAFT_MESSAGE = gmailMessage({
id: 'gmail-draft-message',
threadId: 'gmail-draft-thread',
from: HANDLE,
to: RECIPIENTS.to,
labelIds: ['DRAFT'],
payload: {
mimeType: 'text/plain',
headers: [
{ name: 'From', value: HANDLE },
{ name: 'To', value: RECIPIENTS.to },
{ name: 'Cc', value: RECIPIENTS.cc },
{ name: 'Bcc', value: RECIPIENTS.bcc },
{ name: 'Subject', value: DRAFT_SUBJECT },
{ name: 'Message-ID', value: '<gmail-draft@example.com>' },
{ name: 'Date', value: 'Wed, 15 Nov 2023 00:00:00 +0000' },
],
body: {
data: Buffer.from('Gmail draft body').toString('base64'),
size: 16,
},
},
});
describe('Gmail outbound messaging and calendar creation (integration)', () => {
const google = setupGoogleMock({
handle: HANDLE,
aliases: [ALIAS],
inbox: [PARENT_MESSAGE, DRAFT_MESSAGE],
});
let channel: Awaited<ReturnType<typeof connectMessagingAccount>>;
beforeAll(async () => {
channel = await connectMessagingAccount({
provider: ConnectedAccountProvider.GOOGLE,
handle: HANDLE,
});
await runMessageChannelSync(channel.channelId);
}, 60000);
afterAll(async () => {
await channel?.cleanup().catch(() => undefined);
});
it('sends a reply with recipients and RFC 5322 threading headers', async () => {
const subject = `Gmail outbound ${randomUUID()}`;
const result = await sendEmail({
connectedAccountId: channel.connectedAccountId,
to: RECIPIENTS.to,
cc: RECIPIENTS.cc,
bcc: RECIPIENTS.bcc,
subject,
body: '<p>Gmail reply body</p>',
inReplyTo: '<gmail-parent@example.com>',
});
expect(result).toMatchObject({ success: true });
expect(google.sentMessages).toHaveLength(1);
const [{ raw, threadId }] = google.sentMessages;
expect(threadId).toBe(PARENT_MESSAGE.threadId);
expect(raw).toContain(`To: ${RECIPIENTS.to}`);
expect(raw).toContain(`Cc: ${RECIPIENTS.cc}`);
expect(raw).toContain(`Bcc: ${RECIPIENTS.bcc}`);
expect(raw).toContain(`Subject: ${subject}`);
expect(raw).toContain('In-Reply-To: <gmail-parent@example.com>');
expect(raw).toContain('References: <gmail-parent@example.com>');
const [message] = await findRecordNodesByFilter<{
id: string;
isDraft: boolean;
messageThreadId: string | null;
text: string | null;
}>('message', 'messages', 'id isDraft messageThreadId text', {
subject: { eq: subject },
});
expect(message).toMatchObject({
isDraft: false,
messageThreadId: expect.any(String),
text: 'Gmail reply body',
});
expect(
await findRecordNodesByFilter<{
messageChannelId: string;
}>(
'messageChannelMessageAssociation',
'messageChannelMessageAssociations',
'messageChannelId',
{ messageId: { eq: message.id } },
),
).toEqual([
expect.objectContaining({ messageChannelId: channel.channelId }),
]);
expect(
await findRecordNodesByFilter<{ handle: string; role: string }>(
'messageParticipant',
'messageParticipants',
'handle role',
{ messageId: { eq: message.id } },
),
).toEqual(
expect.arrayContaining([
expect.objectContaining({ handle: RECIPIENTS.to, role: 'TO' }),
expect.objectContaining({ handle: RECIPIENTS.cc, role: 'CC' }),
expect.objectContaining({ handle: RECIPIENTS.bcc, role: 'BCC' }),
]),
);
}, 60000);
it('sends a synced Gmail draft through GraphQL and replaces it in the database', async () => {
const [draft] = await findRecordNodesByFilter<{
id: string;
isDraft: boolean;
}>('message', 'messages', 'id isDraft', {
subject: { eq: DRAFT_SUBJECT },
});
expect(draft).toMatchObject({
isDraft: true,
});
expect(
await findRecordNodesByFilter<{
messageChannelId: string;
messageExternalId: string;
}>(
'messageChannelMessageAssociation',
'messageChannelMessageAssociations',
'messageChannelId messageExternalId',
{ messageId: { eq: draft.id } },
),
).toEqual([
expect.objectContaining({
messageChannelId: channel.channelId,
messageExternalId: DRAFT_MESSAGE.id,
}),
]);
expect(
await findRecordNodesByFilter<{ handle: string; role: string }>(
'messageParticipant',
'messageParticipants',
'handle role',
{ messageId: { eq: draft.id } },
),
).toEqual(
expect.arrayContaining([
expect.objectContaining({ handle: RECIPIENTS.to, role: 'TO' }),
expect.objectContaining({ handle: RECIPIENTS.cc, role: 'CC' }),
expect.objectContaining({ handle: RECIPIENTS.bcc, role: 'BCC' }),
]),
);
const result = await sendEmail({
connectedAccountId: channel.connectedAccountId,
to: RECIPIENTS.to,
cc: RECIPIENTS.cc,
bcc: RECIPIENTS.bcc,
subject: DRAFT_SUBJECT,
body: 'Gmail draft body',
draftMessageId: draft.id,
});
expect(result).toMatchObject({ success: true });
const [sentMessage] = await findRecordNodesByFilter<{
id: string;
isDraft: boolean;
messageThreadId: string | null;
text: string | null;
}>('message', 'messages', 'id isDraft messageThreadId text', {
subject: { eq: DRAFT_SUBJECT },
});
expect(sentMessage).toEqual(
expect.objectContaining({
isDraft: false,
messageThreadId: expect.any(String),
text: 'Gmail draft body',
}),
);
const [association] = await findRecordNodesByFilter<{
messageChannelId: string;
messageExternalId: string;
}>(
'messageChannelMessageAssociation',
'messageChannelMessageAssociations',
'messageChannelId messageExternalId',
{ messageId: { eq: sentMessage.id } },
);
expect(association.messageChannelId).toBe(channel.channelId);
expect(association.messageExternalId).not.toBe(DRAFT_MESSAGE.id);
expect(result.messageThreadId).toEqual(expect.any(String));
}, 60000);
it('imports the verified aliases during sync and sends from one of them', async () => {
const connectedAccount = await getCoreRepository<ConnectedAccountEntity>(
ConnectedAccountEntity,
).findOneByOrFail({ id: channel.connectedAccountId });
expect(connectedAccount.handleAliases).toEqual([ALIAS]);
const subject = `Gmail alias outbound ${randomUUID()}`;
const result = await sendEmail({
connectedAccountId: channel.connectedAccountId,
fromHandle: ALIAS,
to: RECIPIENTS.to,
subject,
body: '<p>Gmail alias body</p>',
});
expect(result).toMatchObject({ success: true });
const [{ raw }] = google.sentMessages.slice(-1);
expect(raw).toContain(`<${ALIAS}>`);
}, 60000);
it('refuses to send from an address the account has not verified', async () => {
const sentMessageCount = google.sentMessages.length;
const result = await sendEmail({
connectedAccountId: channel.connectedAccountId,
fromHandle: 'not-my-alias@apple.dev',
to: RECIPIENTS.to,
subject: `Gmail rejected sender ${randomUUID()}`,
body: '<p>Gmail rejected body</p>',
});
expect(result).toMatchObject({
success: false,
error: expect.stringContaining(
'is not the connected account handle nor one of its verified aliases',
),
});
expect(google.sentMessages).toHaveLength(sentMessageCount);
}, 60000);
it('creates and persists a calendar event with invitations and conferencing', async () => {
const title = `Google calendar outbound ${randomUUID()}`;
const result = await createCalendarEvent({
connectedAccountId: channel.connectedAccountId,
title,
description: 'Planning meeting',
location: 'Room 101',
startsAt: '2026-08-13T09:00:00Z',
endsAt: '2026-08-13T10:00:00Z',
timeZone: 'UTC',
attendees: RECIPIENTS.to,
sendInvitations: true,
addConferencing: true,
});
expect(result).toMatchObject({ success: true });
expect(result.iCalUid).toContain('@google.com');
expect(google.createdCalendarEvents).toEqual([
expect.objectContaining({
summary: title,
attendees: [expect.objectContaining({ email: RECIPIENTS.to })],
conferenceData: expect.objectContaining({
createRequest: expect.any(Object),
}),
}),
]);
expect(await findImportedCalendarEventTitles([title])).toEqual([title]);
}, 60000);
});

View File

@@ -0,0 +1,195 @@
import { randomUUID } from 'node:crypto';
import {
ConnectedAccountProvider,
MessageChannelContactAutoCreationPolicy,
} from 'twenty-shared/types';
import { STANDARD_OBJECTS } from 'twenty-shared/metadata';
import { type TimelineActivityTypeSnapshot } from 'twenty-shared/timeline';
import { CalendarChannelEntity } from 'src/engine/metadata-modules/calendar-channel/entities/calendar-channel.entity';
import { MessageChannelEntity } from 'src/engine/metadata-modules/message-channel/entities/message-channel.entity';
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 { gmailMessage } from 'test/integration/google/mocks/gmail-message.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 { runCalendarChannelEventsImport } from 'test/integration/utils/run-calendar-channel-events-import.util';
import { runCalendarChannelListFetch } from 'test/integration/utils/run-calendar-channel-list-fetch.util';
import { runMessageChannelSync } from 'test/integration/utils/run-message-channel-sync.util';
import { waitForAllJobsToFinish } from 'test/integration/utils/wait-for-all-jobs-to-finish.util';
const HANDLE = 'gmail-existing-person-matching@apple.dev';
const KNOWN_SENDER = `known-sender-${randomUUID()}@acme.com`;
const KNOWN_ATTENDEE = `known-attendee-${randomUUID()}@acme.com`;
type ImportedParticipantLink<
TLinkedRecordIdField extends 'messageId' | 'calendarEventId',
> = {
personId: string | null;
} & Record<TLinkedRecordIdField, string>;
const findParticipantLinks = async <
TLinkedRecordIdField extends 'messageId' | 'calendarEventId',
>(
objectMetadataSingularName: string,
objectMetadataPluralName: string,
handle: string,
linkedRecordIdField: TLinkedRecordIdField,
): Promise<ImportedParticipantLink<TLinkedRecordIdField>[]> =>
findRecordNodesByFilter<ImportedParticipantLink<TLinkedRecordIdField>>(
objectMetadataSingularName,
objectMetadataPluralName,
`personId ${linkedRecordIdField}`,
{ handle: { eq: handle } },
);
const findLinkedTimelineActivities = async ({
personId,
linkedRecordId,
}: {
personId: string;
linkedRecordId: string;
}) =>
findRecordNodesByFilter<{
linkedRecordId: string;
timelineActivityTypeSnapshot: TimelineActivityTypeSnapshot;
}>(
'timelineActivity',
'timelineActivities',
'linkedRecordId timelineActivityTypeSnapshot',
{
targetPersonId: { eq: personId },
linkedRecordId: { eq: linkedRecordId },
},
);
const createPerson = async (primaryEmail: string) => {
const response = await makeGraphqlAPIRequest(
createOneOperationFactory({
objectMetadataSingularName: 'person',
gqlFields: 'id',
data: { emails: { primaryEmail } },
}),
);
expect(response.body.errors).toBeUndefined();
return response.body.data.createPerson.id as string;
};
describe('Participant matching when the person already exists (integration)', () => {
const gmail = setupGoogleMock({
handle: HANDLE,
inbox: [gmailMessage({ from: KNOWN_SENDER, to: HANDLE })],
});
let channel: Awaited<ReturnType<typeof connectMessagingAccount>>;
let senderPersonId: string;
let attendeePersonId: string;
beforeAll(async () => {
senderPersonId = await createPerson(KNOWN_SENDER);
attendeePersonId = await createPerson(KNOWN_ATTENDEE);
await waitForAllJobsToFinish();
channel = await connectMessagingAccount({
provider: ConnectedAccountProvider.GOOGLE,
handle: HANDLE,
});
await getCoreRepository<MessageChannelEntity>(MessageChannelEntity).update(
{ id: channel.channelId },
{
isContactAutoCreationEnabled: false,
contactAutoCreationPolicy: MessageChannelContactAutoCreationPolicy.NONE,
},
);
await getCoreRepository<CalendarChannelEntity>(
CalendarChannelEntity,
).update(
{ id: channel.calendarChannelId },
{ isContactAutoCreationEnabled: false },
);
await runMessageChannelSync(channel.channelId);
gmail.serveCalendarEvents([
googleCalendarEvent({
summary: `Calendar event ${randomUUID()}`,
attendees: [{ email: KNOWN_ATTENDEE }],
}),
]);
await runCalendarChannelListFetch(channel.calendarChannelId);
await runCalendarChannelEventsImport(channel.calendarChannelId);
await waitForAllJobsToFinish();
}, 180000);
afterAll(async () => {
await channel?.cleanup().catch(() => undefined);
});
it('creates a typed timeline activity when importing an email for an existing person', async () => {
const participants = await findParticipantLinks(
'messageParticipant',
'messageParticipants',
KNOWN_SENDER,
'messageId',
);
const [participant] = participants;
expect(participants).toHaveLength(1);
expect(participant.personId).toBe(senderPersonId);
const timelineActivities = await findLinkedTimelineActivities({
personId: senderPersonId,
linkedRecordId: participant.messageId,
});
expect(timelineActivities).toHaveLength(1);
expect(timelineActivities[0]).toMatchObject({
linkedRecordId: participant.messageId,
timelineActivityTypeSnapshot: {
action: 'linked',
objectUniversalIdentifier: STANDARD_OBJECTS.message.universalIdentifier,
},
});
}, 60000);
it('creates a typed timeline activity when importing a calendar event for an existing person', async () => {
const participants = await findParticipantLinks(
'calendarEventParticipant',
'calendarEventParticipants',
KNOWN_ATTENDEE,
'calendarEventId',
);
const [participant] = participants;
expect(participants).toHaveLength(1);
expect(participant.personId).toBe(attendeePersonId);
const timelineActivities = await findLinkedTimelineActivities({
personId: attendeePersonId,
linkedRecordId: participant.calendarEventId,
});
expect(timelineActivities).toHaveLength(1);
expect(timelineActivities[0]).toMatchObject({
linkedRecordId: participant.calendarEventId,
timelineActivityTypeSnapshot: {
action: 'linked',
objectUniversalIdentifier:
STANDARD_OBJECTS.calendarEvent.universalIdentifier,
},
});
}, 60000);
});

View File

@@ -0,0 +1,143 @@
import { randomUUID } from 'node:crypto';
import {
ConnectedAccountProvider,
MessageChannelContactAutoCreationPolicy,
} from 'twenty-shared/types';
import { CalendarChannelEntity } from 'src/engine/metadata-modules/calendar-channel/entities/calendar-channel.entity';
import { MessageChannelEntity } from 'src/engine/metadata-modules/message-channel/entities/message-channel.entity';
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 { gmailMessage } from 'test/integration/google/mocks/gmail-message.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 { runCalendarChannelEventsImport } from 'test/integration/utils/run-calendar-channel-events-import.util';
import { runCalendarChannelListFetch } from 'test/integration/utils/run-calendar-channel-list-fetch.util';
import { runMessageChannelSync } from 'test/integration/utils/run-message-channel-sync.util';
import { waitForAllJobsToFinish } from 'test/integration/utils/wait-for-all-jobs-to-finish.util';
const HANDLE = 'gmail-participant-matching@apple.dev';
const UNMATCHED_HANDLE = `unmatched-${randomUUID()}@acme.com`;
const UNMATCHED_ATTENDEE = `attendee-${randomUUID()}@acme.com`;
const findParticipantPersonIds = async (
objectMetadataSingularName: string,
objectMetadataPluralName: string,
handle: string,
) => {
const participants = await findRecordNodesByFilter<{
personId: string | null;
}>(objectMetadataSingularName, objectMetadataPluralName, 'personId', {
handle: { eq: handle },
});
return participants.map((participant) => participant.personId);
};
describe('Participant matching on person creation (integration)', () => {
const gmail = setupGoogleMock({
handle: HANDLE,
inbox: [gmailMessage({ from: UNMATCHED_HANDLE, to: HANDLE })],
});
let channel: Awaited<ReturnType<typeof connectMessagingAccount>>;
beforeAll(async () => {
channel = await connectMessagingAccount({
provider: ConnectedAccountProvider.GOOGLE,
handle: HANDLE,
});
await getCoreRepository<MessageChannelEntity>(MessageChannelEntity).update(
{ id: channel.channelId },
{
isContactAutoCreationEnabled: false,
contactAutoCreationPolicy: MessageChannelContactAutoCreationPolicy.NONE,
},
);
await getCoreRepository<CalendarChannelEntity>(
CalendarChannelEntity,
).update(
{ id: channel.calendarChannelId },
{ isContactAutoCreationEnabled: false },
);
await runMessageChannelSync(channel.channelId);
gmail.serveCalendarEvents([
googleCalendarEvent({
summary: `Calendar event ${randomUUID()}`,
attendees: [{ email: UNMATCHED_ATTENDEE }],
}),
]);
await runCalendarChannelListFetch(channel.calendarChannelId);
await runCalendarChannelEventsImport(channel.calendarChannelId);
}, 180000);
afterAll(async () => {
await channel?.cleanup().catch(() => undefined);
});
it('leaves the imported participants unmatched while no person owns the handle', async () => {
expect(
await findParticipantPersonIds(
'messageParticipant',
'messageParticipants',
UNMATCHED_HANDLE,
),
).toEqual([null]);
expect(
await findParticipantPersonIds(
'calendarEventParticipant',
'calendarEventParticipants',
UNMATCHED_ATTENDEE,
),
).toEqual([null]);
}, 60000);
it('matches both message and calendar participants when the person is created', async () => {
const createPerson = async (primaryEmail: string) => {
const response = await makeGraphqlAPIRequest(
createOneOperationFactory({
objectMetadataSingularName: 'person',
gqlFields: 'id',
data: { emails: { primaryEmail } },
}),
);
expect(response.body.errors).toBeUndefined();
return response.body.data.createPerson.id as string;
};
const messagePersonId = await createPerson(UNMATCHED_HANDLE);
const attendeePersonId = await createPerson(UNMATCHED_ATTENDEE);
await waitForAllJobsToFinish();
expect(
await findParticipantPersonIds(
'messageParticipant',
'messageParticipants',
UNMATCHED_HANDLE,
),
).toEqual([messagePersonId]);
expect(
await findParticipantPersonIds(
'calendarEventParticipant',
'calendarEventParticipants',
UNMATCHED_ATTENDEE,
),
).toEqual([attendeePersonId]);
}, 120000);
});

View File

@@ -0,0 +1,89 @@
import {
ConnectedAccountProvider,
MessageChannelSyncStage,
} from 'twenty-shared/types';
import { MessageChannelEntity } from 'src/engine/metadata-modules/message-channel/entities/message-channel.entity';
import { MessagingOngoingStaleCronJob } from 'src/modules/messaging/message-import-manager/crons/jobs/messaging-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 { queryMessageChannel } from 'test/integration/utils/query-messaging.util';
import { runSyncCron } from 'test/integration/utils/run-sync-cron.util';
const STALE_HANDLE = 'messaging-stale-sync@apple.dev';
const RECENT_HANDLE = 'messaging-recent-sync@apple.dev';
const STALE_STARTED_AT = new Date(Date.now() - 31 * 60 * 1000);
const RECENT_STARTED_AT = new Date(Date.now() - 60 * 1000);
describe('Messaging 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 queryMessageChannel(connectedChannel);
expect(channelState.syncStage).toBe(
MessageChannelSyncStage.MESSAGE_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 messageChannelRepository =
getCoreRepository<MessageChannelEntity>(MessageChannelEntity);
await messageChannelRepository.update(
{ id: staleChannel.channelId },
{
syncStage: MessageChannelSyncStage.MESSAGE_LIST_FETCH_ONGOING,
syncStageStartedAt: STALE_STARTED_AT,
},
);
await messageChannelRepository.update(
{ id: recentChannel.channelId },
{
syncStage: MessageChannelSyncStage.MESSAGE_LIST_FETCH_ONGOING,
syncStageStartedAt: RECENT_STARTED_AT,
},
);
await runSyncCron(MessagingOngoingStaleCronJob);
const staleChannelAfter = await queryMessageChannel(staleChannel);
expect(staleChannelAfter.syncStage).toBe(
MessageChannelSyncStage.MESSAGE_LIST_FETCH_PENDING,
);
expect(staleChannelAfter.syncStageStartedAt).toBeNull();
const recentChannelAfter = await queryMessageChannel(recentChannel);
expect(recentChannelAfter.syncStage).toBe(
MessageChannelSyncStage.MESSAGE_LIST_FETCH_ONGOING,
);
}, 60000);
});

View File

@@ -0,0 +1,156 @@
import {
ConnectedAccountProvider,
MessageChannelSyncStage,
MessageChannelSyncStatus,
} from 'twenty-shared/types';
import { ConnectedAccountEntity } from 'src/engine/metadata-modules/connected-account/entities/connected-account.entity';
import { MessageChannelEntity } from 'src/engine/metadata-modules/message-channel/entities/message-channel.entity';
import { MESSAGING_THROTTLE_MAX_ATTEMPTS } from 'src/modules/messaging/message-import-manager/constants/messaging-throttle-max-attempts';
import { MessagingMessageListFetchCronJob } from 'src/modules/messaging/message-import-manager/crons/jobs/messaging-message-list-fetch.cron.job';
import { MessagingRelaunchFailedMessageChannelsCronJob } from 'src/modules/messaging/message-import-manager/crons/jobs/messaging-relaunch-failed-message-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 {
queryConnectedAccount,
queryMessageChannel,
updateMessageChannel,
} from 'test/integration/utils/query-messaging.util';
import { runSyncCron } from 'test/integration/utils/run-sync-cron.util';
const THROTTLED_HANDLE = 'messaging-throttled@apple.dev';
const REVOKED_HANDLE = 'messaging-revoked@apple.dev';
const FUTURE_RETRY_AFTER_ISO = '2099-12-31T10:30:00.000Z';
const ONE_HOUR_AGO = new Date(Date.now() - 60 * 60 * 1000);
const EXPIRED_CREDENTIALS_AT = new Date(Date.now() - 56 * 60 * 1000);
describe('Messaging 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 updateMessageChannel(revokedChannel.channelId, {
isSyncEnabled: false,
});
}, 120000);
afterAll(async () => {
await throttledChannel?.cleanup().catch(() => undefined);
await revokedChannel?.cleanup().catch(() => undefined);
});
it('records the throttle backoff on a 429 and keeps the channel alive', async () => {
gmail.rateLimitMessageList(FUTURE_RETRY_AFTER_ISO);
await runSyncCron(MessagingMessageListFetchCronJob);
const channelState = await queryMessageChannel(throttledChannel);
expect(channelState.throttleFailureCount).toBe(1);
expect(channelState.throttleRetryAfter).toBe(FUTURE_RETRY_AFTER_ISO);
expect(channelState.syncStatus).not.toBe(
MessageChannelSyncStatus.FAILED_UNKNOWN,
);
}, 60000);
it('fails the channel as unknown once the throttle attempts are exhausted', async () => {
gmail.rateLimitMessageList(FUTURE_RETRY_AFTER_ISO);
const messageChannelRepository =
getCoreRepository<MessageChannelEntity>(MessageChannelEntity);
let channelState = await queryMessageChannel(throttledChannel);
for (
let attempt = channelState.throttleFailureCount;
attempt <= MESSAGING_THROTTLE_MAX_ATTEMPTS &&
channelState.syncStatus !== MessageChannelSyncStatus.FAILED_UNKNOWN;
attempt++
) {
await messageChannelRepository.update(
{ id: throttledChannel.channelId },
{ syncStageStartedAt: ONE_HOUR_AGO, throttleRetryAfter: null },
);
await runSyncCron(MessagingMessageListFetchCronJob);
channelState = await queryMessageChannel(throttledChannel);
}
expect(channelState.syncStatus).toBe(
MessageChannelSyncStatus.FAILED_UNKNOWN,
);
expect(channelState.syncStage).toBe(MessageChannelSyncStage.FAILED);
}, 120000);
it('fails the channel as insufficient-permissions when the refresh token is declined', async () => {
gmail.actAsAccount(REVOKED_HANDLE);
await updateMessageChannel(revokedChannel.channelId, {
isSyncEnabled: true,
});
await getCoreRepository<ConnectedAccountEntity>(
ConnectedAccountEntity,
).update(
{ id: revokedChannel.connectedAccountId },
{ lastCredentialsRefreshedAt: EXPIRED_CREDENTIALS_AT },
);
gmail.declineTokenRefresh();
await runSyncCron(MessagingMessageListFetchCronJob);
const channelState = await queryMessageChannel(revokedChannel);
expect(channelState.syncStatus).toBe(
MessageChannelSyncStatus.FAILED_INSUFFICIENT_PERMISSIONS,
);
expect(channelState.syncStage).toBe(MessageChannelSyncStage.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(MessagingRelaunchFailedMessageChannelsCronJob);
const relaunchedChannel = await queryMessageChannel(throttledChannel);
expect(relaunchedChannel.syncStatus).toBe(MessageChannelSyncStatus.ACTIVE);
expect(relaunchedChannel.syncStage).toBe(
MessageChannelSyncStage.MESSAGE_LIST_FETCH_PENDING,
);
expect(relaunchedChannel.throttleFailureCount).toBe(0);
expect(relaunchedChannel.throttleRetryAfter).toBeNull();
expect(relaunchedChannel.syncStageStartedAt).toBeNull();
const untouchedChannel = await queryMessageChannel(revokedChannel);
expect(untouchedChannel.syncStatus).toBe(
MessageChannelSyncStatus.FAILED_INSUFFICIENT_PERMISSIONS,
);
expect(untouchedChannel.syncStage).toBe(MessageChannelSyncStage.FAILED);
}, 60000);
});

View File

@@ -0,0 +1,124 @@
import {
ConnectedAccountProvider,
MessageChannelSyncStage,
MessageChannelSyncStatus,
} from 'twenty-shared/types';
import { ConnectedAccountEntity } from 'src/engine/metadata-modules/connected-account/entities/connected-account.entity';
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 { queryMessageChannel } from 'test/integration/utils/query-messaging.util';
import { resetMessageChannelSyncState } from 'test/integration/utils/reset-channel-sync-state.util';
import { runMessageChannelSync } from 'test/integration/utils/run-message-channel-sync.util';
const HANDLE = 'gmail-token-failure-modes@apple.dev';
// The refresh service only mints a new token once the stored credentials are
// stale; a recent refresh short-circuits to the tokens already on the account.
const STALE_CREDENTIALS_AT = new Date(Date.now() - 56 * 60 * 1000);
const FRESH_CREDENTIALS_AT = new Date();
describe('Gmail token failure modes (integration)', () => {
setupGoogleMock({ handle: HANDLE });
const connectedAccountRepository = getCoreRepository<ConnectedAccountEntity>(
ConnectedAccountEntity,
);
let channel: Awaited<ReturnType<typeof connectMessagingAccount>>;
let storedAccount: ConnectedAccountEntity;
const restoreAccount = () =>
connectedAccountRepository.update(
{ id: channel.connectedAccountId },
{
accessToken: storedAccount.accessToken,
refreshToken: storedAccount.refreshToken,
provider: storedAccount.provider,
lastCredentialsRefreshedAt: storedAccount.lastCredentialsRefreshedAt,
authFailedAt: null,
},
);
beforeAll(async () => {
channel = await connectMessagingAccount({
provider: ConnectedAccountProvider.GOOGLE,
handle: HANDLE,
});
storedAccount = await connectedAccountRepository.findOneByOrFail({
id: channel.connectedAccountId,
});
}, 60000);
afterAll(async () => {
await restoreAccount().catch(() => undefined);
await channel?.cleanup().catch(() => undefined);
});
beforeEach(async () => {
await restoreAccount();
await resetMessageChannelSyncState(channel.channelId);
});
it('fails the channel as insufficient-permissions when the refresh token is gone', async () => {
await connectedAccountRepository.update(
{ id: channel.connectedAccountId },
{
refreshToken: null,
lastCredentialsRefreshedAt: STALE_CREDENTIALS_AT,
},
);
await runMessageChannelSync(channel.channelId);
const channelState = await queryMessageChannel(channel);
expect(channelState.syncStatus).toBe(
MessageChannelSyncStatus.FAILED_INSUFFICIENT_PERMISSIONS,
);
expect(channelState.syncStage).toBe(MessageChannelSyncStage.FAILED);
}, 60000);
it('fails the channel as unknown when the stored access token is gone', async () => {
await connectedAccountRepository.update(
{ id: channel.connectedAccountId },
{
accessToken: null,
lastCredentialsRefreshedAt: FRESH_CREDENTIALS_AT,
},
);
await runMessageChannelSync(channel.channelId);
const channelState = await queryMessageChannel(channel);
expect(channelState.syncStatus).toBe(
MessageChannelSyncStatus.FAILED_UNKNOWN,
);
expect(channelState.syncStage).toBe(MessageChannelSyncStage.FAILED);
}, 60000);
// The driver rejects the unsupported provider before the refresh service is
// reached, so this surfaces as a permissions failure.
it('fails the channel as insufficient-permissions when the provider cannot refresh tokens', async () => {
await connectedAccountRepository.update(
{ id: channel.connectedAccountId },
{
provider: ConnectedAccountProvider.IMAP_SMTP_CALDAV,
lastCredentialsRefreshedAt: STALE_CREDENTIALS_AT,
},
);
await runMessageChannelSync(channel.channelId);
const channelState = await queryMessageChannel(channel);
expect(channelState.syncStatus).toBe(
MessageChannelSyncStatus.FAILED_INSUFFICIENT_PERMISSIONS,
);
expect(channelState.syncStage).toBe(MessageChannelSyncStage.FAILED);
}, 60000);
});

View File

@@ -0,0 +1,49 @@
import { ConnectedAccountProvider } from 'twenty-shared/types';
import { ConnectedAccountEntity } from 'src/engine/metadata-modules/connected-account/entities/connected-account.entity';
import { gmailMessage } from 'test/integration/google/mocks/gmail-message.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 { queryConnectedAccount } from 'test/integration/utils/query-messaging.util';
import { runMessageChannelSync } from 'test/integration/utils/run-message-channel-sync.util';
const HANDLE = 'messaging-token-refresh@apple.dev';
const EXPIRED_CREDENTIALS_AT = new Date(Date.now() - 56 * 60 * 1000);
describe('Messaging token refresh (integration)', () => {
setupGoogleMock({ handle: HANDLE, inbox: [gmailMessage()] });
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('refreshes expired credentials and completes the sync', async () => {
await getCoreRepository<ConnectedAccountEntity>(
ConnectedAccountEntity,
).update(
{ id: channel.connectedAccountId },
{ lastCredentialsRefreshedAt: EXPIRED_CREDENTIALS_AT },
);
await runMessageChannelSync(channel.channelId);
const account = await queryConnectedAccount(channel.connectedAccountId);
expect(account.authFailedAt).toBeNull();
expect(
new Date(account.lastCredentialsRefreshedAt ?? 0).getTime(),
).toBeGreaterThan(EXPIRED_CREDENTIALS_AT.getTime());
}, 60000);
});

View File

@@ -0,0 +1,141 @@
import { randomUUID } from 'node:crypto';
import {
ConnectedAccountProvider,
MessageChannelSyncStage,
MessageChannelSyncStatus,
} from 'twenty-shared/types';
import { POSTGRESQL_ERROR_CODES } from 'src/engine/api/graphql/workspace-query-runner/constants/postgres-error-codes.constants';
import { MessagingMessageParticipantService } from 'src/modules/messaging/message-participant-manager/services/messaging-message-participant.service';
import { gmailMessage } from 'test/integration/google/mocks/gmail-message.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 { queryMessageChannel } from 'test/integration/utils/query-messaging.util';
import { resetMessageChannelSyncState } from 'test/integration/utils/reset-channel-sync-state.util';
import { runMessageChannelSync } from 'test/integration/utils/run-message-channel-sync.util';
const HANDLE = 'messaging-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.
// The mock inbox is fixed at setup time, so every test needs its own message
// served out of it rather than a freshly built one.
const INBOX_MESSAGES = Array.from({ length: 2 }, () =>
gmailMessage({ from: `sender-${randomUUID()}@acme.com`, to: HANDLE }),
);
describe('Messaging import transient database errors (integration)', () => {
const google = setupGoogleMock({ handle: HANDLE, inbox: INBOX_MESSAGES });
let channel: Awaited<ReturnType<typeof connectMessagingAccount>>;
let messageParticipantService: MessagingMessageParticipantService;
const serveInboundMessage = async (messageIndex: number): Promise<void> => {
google.serveMessageList([INBOX_MESSAGES[messageIndex]]);
await resetMessageChannelSyncState(channel.channelId, '');
};
beforeAll(async () => {
channel = await connectMessagingAccount({
provider: ConnectedAccountProvider.GOOGLE,
handle: HANDLE,
});
messageParticipantService =
getAppProviderByClassName<MessagingMessageParticipantService>(
'MessagingMessageParticipantService',
);
}, 60000);
afterAll(async () => {
await channel?.cleanup().catch(() => undefined);
});
afterEach(() => {
jest.restoreAllMocks();
});
it('should reschedule the message channel instead of failing it when the connection is killed by the idle-in-transaction timeout', async () => {
await serveInboundMessage(0);
jest
.spyOn(messageParticipantService, 'saveMessageParticipants')
.mockImplementation(
async (_participants, _workspaceId, transactionScope) => {
await transactionScope.executeRawQuery(
raiseSqlState(
POSTGRESQL_ERROR_CODES.IDLE_IN_TRANSACTION_SESSION_TIMEOUT,
),
);
return [];
},
);
await runMessageChannelSync(channel.channelId);
const channelState = await queryMessageChannel(channel);
expect(channelState.throttleFailureCount).toBe(1);
expect(channelState.syncStage).toBe(
MessageChannelSyncStage.MESSAGES_IMPORT_PENDING,
);
expect(channelState.syncStatus).toBe(MessageChannelSyncStatus.ONGOING);
}, 120000);
it('should have ended the import transaction before the message participants are matched', async () => {
await serveInboundMessage(1);
const saveMessageParticipants =
messageParticipantService.saveMessageParticipants.bind(
messageParticipantService,
);
const matchMessageParticipants =
messageParticipantService.matchMessageParticipants.bind(
messageParticipantService,
);
let importBackendPid: number | undefined;
let importBackendStateWhileMatching: string | undefined;
jest
.spyOn(messageParticipantService, 'saveMessageParticipants')
.mockImplementation(
async (participants, workspaceId, transactionScope) => {
const backends = await transactionScope.executeRawQuery(
'SELECT pg_backend_pid() AS pid',
);
importBackendPid = Number(backends[0].pid);
return saveMessageParticipants(
participants,
workspaceId,
transactionScope,
);
},
);
jest
.spyOn(messageParticipantService, 'matchMessageParticipants')
.mockImplementation(async (args) => {
importBackendStateWhileMatching = await readBackendState(
importBackendPid ?? 0,
);
return matchMessageParticipants(args);
});
await runMessageChannelSync(channel.channelId);
expect(importBackendPid).toEqual(expect.any(Number));
expect(importBackendStateWhileMatching).toBeDefined();
expect(importBackendStateWhileMatching).not.toBe('idle in transaction');
}, 120000);
});

View File

@@ -0,0 +1,20 @@
import { type gmail_v1 } from 'googleapis';
import { http, HttpResponse } from 'msw';
import { type MswHandler } from 'test/integration/utils/http-mock.util';
export const gmailHistoryHandler = (
addedMessages: gmail_v1.Schema$Message[],
): MswHandler =>
http.get('*/gmail/v1/users/me/history', () =>
HttpResponse.json<gmail_v1.Schema$ListHistoryResponse>({
history: [
{
messagesAdded: addedMessages.map((message) => ({
message: { id: message.id, threadId: message.threadId },
})),
},
],
historyId: '987654322',
}),
);

View File

@@ -0,0 +1,77 @@
import { type gmail_v1 } from 'googleapis';
import { http, HttpResponse } from 'msw';
import { gmailMessageListHandler } from 'test/integration/google/mocks/gmail-message-list-handler.util';
import { type MswHandler } from 'test/integration/utils/http-mock.util';
import { type MockEntityStore } from 'test/integration/utils/mock-entity-store.util';
const buildBatchMultipartResponse = (
messages: gmail_v1.Schema$Message[],
): { body: string; contentType: string } => {
const boundary = 'batch_boundary';
const subResponses = messages
.map((message) =>
[
`--${boundary}`,
'Content-Type: application/http',
'',
'HTTP/1.1 200 OK',
'Content-Type: application/json; charset=UTF-8',
'',
JSON.stringify(message),
].join('\r\n'),
)
.join('\r\n');
return {
body: `${subResponses}\r\n--${boundary}--`,
contentType: `multipart/mixed; boundary=${boundary}`,
};
};
export const gmailMailboxHandlers = (
inbox: gmail_v1.Schema$Message[],
labelStore: MockEntityStore<gmail_v1.Schema$Label>,
): MswHandler[] => [
http.get('*/gmail/v1/users/me/labels', () =>
HttpResponse.json<gmail_v1.Schema$ListLabelsResponse>({
labels: labelStore.list(),
}),
),
gmailMessageListHandler(inbox),
http.get('*/gmail/v1/users/me/history', () =>
HttpResponse.json<gmail_v1.Schema$ListHistoryResponse>({
history: [],
historyId: inbox[0]?.historyId ?? '987654321',
}),
),
http.get('*/gmail/v1/users/me/messages/:messageId', ({ params }) => {
const message = inbox.find(
(candidate) => candidate.id === params.messageId,
);
if (!message) {
return HttpResponse.json(
{ error: { code: 404, message: 'Not Found' } },
{ status: 404 },
);
}
return HttpResponse.json<gmail_v1.Schema$Message>(message);
}),
http.post('*/batch', async ({ request }) => {
const requestedIds = [
...(await request.text()).matchAll(/messages\/([\w-]+)/g),
].map((match) => match[1]);
const requestedMessages = inbox.filter((message) =>
requestedIds.includes(message.id ?? ''),
);
const { body, contentType } =
buildBatchMultipartResponse(requestedMessages);
return new HttpResponse(body, {
headers: { 'Content-Type': contentType },
});
}),
];

View File

@@ -0,0 +1,34 @@
import { type gmail_v1 } from 'googleapis';
import { http, HttpResponse } from 'msw';
import { type MswHandler } from 'test/integration/utils/http-mock.util';
// Only the onboarding import sends a positive `in:` term. The full sync's
// `-label:` / `-category:` exclusions and `label:` inclusions stay unapplied.
const INCLUDED_LABEL_REGEX = /(?:^|\s)in:(\w+)/g;
const matchesSearchFilter = (
message: gmail_v1.Schema$Message,
searchFilter: string,
): boolean =>
[...searchFilter.matchAll(INCLUDED_LABEL_REGEX)].every(([, labelName]) =>
message.labelIds?.includes(labelName.toUpperCase()),
);
export const gmailMessageListHandler = (
messages: gmail_v1.Schema$Message[],
): MswHandler =>
http.get('*/gmail/v1/users/me/messages', ({ request }) => {
const searchFilter = new URL(request.url).searchParams.get('q') ?? '';
const matchingMessages = messages.filter((message) =>
matchesSearchFilter(message, searchFilter),
);
return HttpResponse.json<gmail_v1.Schema$ListMessagesResponse>({
messages: matchingMessages.map((message) => ({
id: message.id,
threadId: message.threadId,
})),
resultSizeEstimate: matchingMessages.length,
});
});

View File

@@ -0,0 +1,7 @@
import { type gmail_v1 } from 'googleapis';
export const getGmailMessageSubject = (
message: gmail_v1.Schema$Message,
): string =>
message.payload?.headers?.find((header) => header.name === 'Subject')
?.value ?? '';

View File

@@ -0,0 +1,39 @@
import { randomUUID } from 'node:crypto';
import { type gmail_v1 } from 'googleapis';
type GmailMessageOverrides = Partial<gmail_v1.Schema$Message> & {
from?: string;
to?: string;
};
export const gmailMessage = ({
from,
to,
...overrides
}: GmailMessageOverrides = {}): gmail_v1.Schema$Message => {
const id = overrides.id ?? `gmail-msg-${randomUUID()}`;
return {
id,
threadId: id,
historyId: '987654321',
internalDate: '1700000000000',
labelIds: ['INBOX'],
payload: {
mimeType: 'text/plain',
headers: [
{ name: 'From', value: from ?? `sender-${id}@example.com` },
{ name: 'To', value: to ?? `recipient-${id}@example.com` },
{ name: 'Subject', value: `Subject ${id}` },
{ name: 'Message-ID', value: `<${id}@example.com>` },
{ name: 'Date', value: 'Wed, 15 Nov 2023 00:00:00 +0000' },
],
body: {
data: Buffer.from(`body ${id}`).toString('base64'),
size: 10,
},
},
...overrides,
};
};

View File

@@ -0,0 +1,22 @@
import { randomUUID } from 'node:crypto';
import { type calendar_v3 } from 'googleapis';
export const googleCalendarEvent = (
overrides: Partial<calendar_v3.Schema$Event> = {},
): calendar_v3.Schema$Event => {
const id = overrides.id ?? `google-calendar-event-${randomUUID()}`;
return {
id,
iCalUID: `${id}@google.com`,
summary: `Calendar event ${id}`,
status: 'confirmed',
start: { dateTime: '2023-11-15T10:00:00Z' },
end: { dateTime: '2023-11-15T11:00:00Z' },
created: '2023-11-01T00:00:00.000Z',
updated: '2023-11-01T00:00:00.000Z',
attendees: [],
...overrides,
};
};

View File

@@ -0,0 +1,22 @@
import { type calendar_v3 } from 'googleapis';
import { http, HttpResponse } from 'msw';
import { GOOGLE_CALENDAR_EVENTS_URL } from 'test/integration/google/mocks/google-calendar-events-url.constant';
import { type MswHandler } from 'test/integration/utils/http-mock.util';
export const googleCalendarEventsHandlers = (
events: calendar_v3.Schema$Event[],
nextSyncToken: string,
): MswHandler[] => [
http.get(GOOGLE_CALENDAR_EVENTS_URL, () =>
HttpResponse.json<calendar_v3.Schema$Events>({
items: events,
nextSyncToken,
}),
),
...events.map((event) =>
http.get(`${GOOGLE_CALENDAR_EVENTS_URL}/${event.id}`, () =>
HttpResponse.json<calendar_v3.Schema$Event>(event),
),
),
];

View File

@@ -0,0 +1,2 @@
export const GOOGLE_CALENDAR_EVENTS_URL =
'https://www.googleapis.com/calendar/v3/calendars/primary/events';

View File

@@ -0,0 +1,37 @@
import { http, HttpResponse } from 'msw';
import { GOOGLE_OAUTH_SCOPES } from 'test/integration/google/mocks/google-oauth-scopes.constant';
import { type MswHandler } from 'test/integration/utils/http-mock.util';
export const googleIdentityHandlers = (
handle: string,
aliases: string[] = [],
): MswHandler[] => [
http.get('https://www.googleapis.com/oauth2/v3/userinfo', () =>
HttpResponse.json({
sub: `google-user-id-${handle}`,
email: handle,
email_verified: true,
name: 'Jane Austen',
given_name: 'Jane',
family_name: 'Austen',
}),
),
http.get('https://www.googleapis.com/oauth2/v3/tokeninfo', () =>
HttpResponse.json({ scope: GOOGLE_OAUTH_SCOPES, email: handle }),
),
http.get('https://gmail.googleapis.com/gmail/v1/users/me/profile', () =>
HttpResponse.json({ emailAddress: handle, messagesTotal: 0 }),
),
http.get('https://people.googleapis.com/v1/people/me', () =>
HttpResponse.json({ names: [{ displayName: 'Jane Austen' }] }),
),
http.get('*/gmail/v1/users/me/settings/sendAs', () =>
HttpResponse.json({
sendAs: [
{ sendAsEmail: handle, isPrimary: true },
...aliases.map((alias) => ({ sendAsEmail: alias, isPrimary: false })),
],
}),
),
];

View File

@@ -0,0 +1,9 @@
export const GOOGLE_OAUTH_SCOPES = [
'email',
'profile',
'https://www.googleapis.com/auth/gmail.readonly',
'https://www.googleapis.com/auth/calendar.events',
'https://www.googleapis.com/auth/profile.emails.read',
'https://www.googleapis.com/auth/gmail.send',
'https://www.googleapis.com/auth/gmail.compose',
].join(' ');

View File

@@ -0,0 +1,22 @@
import { http, HttpResponse } from 'msw';
import { GOOGLE_OAUTH_SCOPES } from 'test/integration/google/mocks/google-oauth-scopes.constant';
import { type MswHandler } from 'test/integration/utils/http-mock.util';
export const GOOGLE_TOKEN_URLS = [
'https://oauth2.googleapis.com/token',
'https://www.googleapis.com/oauth2/v4/token',
];
export const googleTokenHandlers = (): MswHandler[] =>
GOOGLE_TOKEN_URLS.map((url) =>
http.post(url, () =>
HttpResponse.json({
access_token: 'mock-access-token',
refresh_token: 'mock-refresh-token',
expires_in: 3600,
scope: GOOGLE_OAUTH_SCOPES,
token_type: 'Bearer',
}),
),
);

View File

@@ -0,0 +1,231 @@
import { type calendar_v3, type gmail_v1 } from 'googleapis';
import { http, HttpResponse } from 'msw';
import { gmailHistoryHandler } from 'test/integration/google/mocks/gmail-history-handler.util';
import { gmailMailboxHandlers } from 'test/integration/google/mocks/gmail-mailbox-handlers.util';
import { gmailMessageListHandler } from 'test/integration/google/mocks/gmail-message-list-handler.util';
import { googleCalendarEventsHandlers } from 'test/integration/google/mocks/google-calendar-events-handlers.util';
import { GOOGLE_CALENDAR_EVENTS_URL } from 'test/integration/google/mocks/google-calendar-events-url.constant';
import { googleIdentityHandlers } from 'test/integration/google/mocks/google-identity-handlers.util';
import {
GOOGLE_TOKEN_URLS,
googleTokenHandlers,
} from 'test/integration/google/mocks/google-token-handlers.util';
import { setupHttpMock } from 'test/integration/utils/http-mock.util';
import {
createMockEntityStore,
type MockEntityStore,
} from 'test/integration/utils/mock-entity-store.util';
const DEFAULT_LABELS: gmail_v1.Schema$Label[] = [
{ id: 'INBOX', name: 'INBOX', type: 'system' },
{ id: 'SENT', name: 'SENT', type: 'system' },
];
export type GoogleMock = {
labels: MockEntityStore<gmail_v1.Schema$Label>;
sentMessages: Array<{ raw: string; threadId?: string }>;
draftMessages: Array<{ raw: string; threadId?: string }>;
createdCalendarEvents: calendar_v3.Schema$Event[];
actAsAccount: (handle: string) => void;
serveMessageList: (messages: gmail_v1.Schema$Message[]) => void;
serveHistory: (addedMessages: gmail_v1.Schema$Message[]) => void;
serveCalendarEvents: (
events: calendar_v3.Schema$Event[],
options?: { nextSyncToken?: string },
) => void;
rateLimitMessageList: (retryAfterIso: string) => void;
rateLimitCalendarEventList: () => void;
failMessageList: (failure: GoogleApiFailure) => void;
failCalendarEventList: (failure: GoogleApiFailure) => void;
declineTokenRefresh: () => void;
};
export type GoogleApiFailure = {
status: number;
reason: string;
message: string;
};
const googleApiErrorResponse = ({
status,
reason,
message,
}: GoogleApiFailure) =>
HttpResponse.json(
{
error: {
code: status,
message,
errors: [{ reason, message }],
},
},
{ status },
);
export const setupGoogleMock = ({
handle,
aliases = [],
inbox = [],
labels = DEFAULT_LABELS,
}: {
handle: string;
aliases?: string[];
inbox?: gmail_v1.Schema$Message[];
labels?: gmail_v1.Schema$Label[];
}): GoogleMock => {
const labelStore = createMockEntityStore(labels, (label) => label.id ?? '');
const sentMessages: Array<{ raw: string; threadId?: string }> = [];
const draftMessages: Array<{ raw: string; threadId?: string }> = [];
const createdCalendarEvents: calendar_v3.Schema$Event[] = [];
const httpMock = setupHttpMock(
...googleTokenHandlers(),
...googleIdentityHandlers(handle, aliases),
...googleCalendarEventsHandlers([], 'mock-calendar-sync-token'),
...gmailMailboxHandlers(inbox, labelStore),
http.post('*/gmail/v1/users/me/messages/send', async ({ request }) => {
const body = (await request.json()) as { raw: string; threadId?: string };
sentMessages.push({
raw: Buffer.from(body.raw, 'base64url').toString(),
threadId: body.threadId,
});
const id = `gmail-sent-${sentMessages.length}`;
return HttpResponse.json({ id, threadId: body.threadId ?? id });
}),
http.post('*/gmail/v1/users/me/drafts', async ({ request }) => {
const body = (await request.json()) as {
message: { raw: string; threadId?: string };
};
draftMessages.push({
raw: Buffer.from(body.message.raw, 'base64url').toString(),
threadId: body.message.threadId,
});
return HttpResponse.json({ id: `gmail-draft-${draftMessages.length}` });
}),
http.get('*/gmail/v1/users/me/drafts', () =>
HttpResponse.json({
drafts: [
{
id: 'gmail-synced-draft',
message: { id: 'gmail-draft-message' },
},
],
}),
),
http.delete(
'*/gmail/v1/users/me/drafts/:draftId',
() => new HttpResponse(null, { status: 204 }),
),
http.post(GOOGLE_CALENDAR_EVENTS_URL, async ({ request }) => {
const event = (await request.json()) as calendar_v3.Schema$Event;
const id = `google-created-calendar-event-${createdCalendarEvents.length + 1}`;
const createdEvent = {
...event,
id,
iCalUID: `${id}@google.com`,
status: 'confirmed',
created: '2026-08-13T00:00:00.000Z',
updated: '2026-08-13T00:00:00.000Z',
};
createdCalendarEvents.push(createdEvent);
return HttpResponse.json(createdEvent);
}),
);
return {
labels: labelStore,
sentMessages,
draftMessages,
createdCalendarEvents,
actAsAccount: (accountHandle) =>
httpMock.use(...googleIdentityHandlers(accountHandle)),
serveMessageList: (messages) =>
httpMock.use(gmailMessageListHandler(messages)),
serveHistory: (addedMessages) =>
httpMock.use(gmailHistoryHandler(addedMessages)),
serveCalendarEvents: (
events,
{ nextSyncToken = 'mock-calendar-sync-token' } = {},
) => httpMock.use(...googleCalendarEventsHandlers(events, nextSyncToken)),
rateLimitMessageList: (retryAfterIso) =>
httpMock.use(
http.get('*/gmail/v1/users/me/messages', () =>
HttpResponse.json(
{
error: {
code: 429,
message: 'Rate Limit Exceeded',
errors: [
{
reason: 'rateLimitExceeded',
message: `Rate Limit Exceeded. Retry after ${retryAfterIso}`,
},
],
},
},
{ status: 429 },
),
),
),
rateLimitCalendarEventList: () =>
httpMock.use(
http.get(GOOGLE_CALENDAR_EVENTS_URL, () =>
HttpResponse.json(
{
error: {
code: 429,
message: 'Rate Limit Exceeded',
errors: [
{
reason: 'rateLimitExceeded',
message: 'Rate Limit Exceeded',
},
],
},
},
{ status: 429 },
),
),
),
// A channel carrying a sync cursor fetches through the history endpoint
// rather than the message list, so both have to fail for the failure to
// surface regardless of which path the channel takes.
failMessageList: (failure) =>
httpMock.use(
http.get('*/gmail/v1/users/me/messages', () =>
googleApiErrorResponse(failure),
),
http.get('*/gmail/v1/users/me/history', () =>
googleApiErrorResponse(failure),
),
),
failCalendarEventList: (failure) =>
httpMock.use(
http.get(GOOGLE_CALENDAR_EVENTS_URL, () =>
googleApiErrorResponse(failure),
),
),
declineTokenRefresh: () =>
httpMock.use(
...GOOGLE_TOKEN_URLS.map((url) =>
http.post(url, () =>
HttpResponse.json(
{
error: 'invalid_grant',
error_description: 'Token has been revoked',
},
{ status: 400 },
),
),
),
),
};
};