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