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,50 @@
import { ImapFlow } from 'imapflow';
// Dovecot only stores and serves mailboxes, so mail is placed with APPEND
// rather than delivered over SMTP. This also removes the delivery race an SMTP
// hand-off introduces between sending and the message becoming fetchable.
export const appendMessageOverImap = async ({
host,
port,
username,
password,
folder,
from,
to,
subject,
}: {
host: string;
port: number;
username: string;
password: string;
folder: string;
from: string;
to: string;
subject: string;
}): Promise<void> => {
const client = new ImapFlow({
host,
port,
secure: false,
auth: { user: username, pass: password },
logger: false,
});
await client.connect();
try {
const message = [
`From: ${from}`,
`To: ${to}`,
`Subject: ${subject}`,
'Content-Type: text/plain; charset=utf-8',
'',
`body of ${subject}`,
'',
].join('\r\n');
await client.append(folder, Buffer.from(message));
} finally {
await client.logout();
}
};

View File

@@ -0,0 +1,57 @@
import { EmailConnectionSecurity } from 'src/engine/core-modules/imap-smtp-caldav-connection/enums/email-connection-security.enum';
import { MessageChannelEntity } from 'src/engine/metadata-modules/message-channel/entities/message-channel.entity';
import { saveImapSmtpCaldavAccount } from 'test/integration/metadata/suites/connected-account/utils/save-imap-smtp-caldav-account.util';
import { updateConfigVariable } from 'test/integration/twenty-config/utils/update-config-variable.util';
import {
type DovecotServer,
startDovecotContainer,
} from 'test/integration/utils/start-dovecot-container.util';
import { getCoreRepository } from 'test/integration/utils/get-core-repository.util';
export type DovecotImapAccount = {
dovecot: DovecotServer;
connectedAccountId: string;
messageChannelId: string;
};
// The IMAP driver routes through the SSRF guard, which rejects the container's
// private address before the connection is attempted.
export const connectDovecotImapAccount = async ({
handle,
password,
}: {
handle: string;
password: string;
}): Promise<DovecotImapAccount> => {
await updateConfigVariable({
input: { key: 'OUTBOUND_HTTP_ALLOWED_INTERNAL_HOSTS', value: ['*'] },
});
const dovecot = await startDovecotContainer({ password });
const { data } = await saveImapSmtpCaldavAccount({
input: {
handle,
connectionParameters: {
IMAP: {
host: dovecot.host,
port: dovecot.imapPort,
username: handle,
password,
connectionSecurity: EmailConnectionSecurity.NONE,
},
},
},
expectToFail: false,
});
const connectedAccountId = data.connectedAccountId;
const messageChannelId = (
await getCoreRepository<MessageChannelEntity>(
MessageChannelEntity,
).findOneByOrFail({ connectedAccountId })
).id;
return { dovecot, connectedAccountId, messageChannelId };
};

View File

@@ -0,0 +1,87 @@
import request from 'supertest';
import {
CalendarChannelVisibility,
ConnectedAccountProvider,
MessageChannelVisibility,
} from 'twenty-shared/types';
import { generateTransientToken } from 'test/integration/utils/generate-transient-token.util';
import {
deleteConnectedAccount,
queryCalendarChannels,
queryMessageChannels,
} from 'test/integration/utils/query-messaging.util';
import { waitForAllJobsToFinish } from 'test/integration/utils/wait-for-all-jobs-to-finish.util';
type ConnectMessagingAccountInput = {
provider: ConnectedAccountProvider;
handle: string;
skipChannelConfiguration?: boolean;
};
type ConnectMessagingAccountResult = {
channelId: string;
calendarChannelId: string;
connectedAccountId: string;
handle: string;
cleanup: () => Promise<void>;
};
const OAUTH_CALLBACK_PATH: Partial<Record<ConnectedAccountProvider, string>> = {
[ConnectedAccountProvider.GOOGLE]: '/auth/google-apis/get-access-token',
[ConnectedAccountProvider.MICROSOFT]: '/auth/microsoft-apis/get-access-token',
};
export const connectMessagingAccount = async ({
provider,
handle,
skipChannelConfiguration = true,
}: ConnectMessagingAccountInput): Promise<ConnectMessagingAccountResult> => {
const callbackPath = OAUTH_CALLBACK_PATH[provider];
if (!callbackPath) {
throw new Error(`Unsupported OAuth provider: ${provider}`);
}
const state = JSON.stringify({
transientToken: await generateTransientToken(),
messageVisibility: MessageChannelVisibility.SHARE_EVERYTHING,
calendarVisibility: CalendarChannelVisibility.SHARE_EVERYTHING,
skipMessageChannelConfiguration: skipChannelConfiguration,
});
const callbackResponse = await request(`http://localhost:${APP_PORT}`)
.get(callbackPath)
.query({ code: 'mock-authorization-code', state });
await waitForAllJobsToFinish();
const connectedChannel = (await queryMessageChannels()).find(
(channel) => channel.handle === handle,
);
if (!connectedChannel) {
throw new Error(
`OAuth connect for ${provider} created no message channel for ${handle} (callback redirected to ${callbackResponse.headers.location})`,
);
}
const [calendarChannel] = await queryCalendarChannels(
connectedChannel.connectedAccountId,
);
if (!calendarChannel) {
throw new Error(
`OAuth connect for ${provider} created no calendar channel for ${handle}`,
);
}
return {
channelId: connectedChannel.id,
calendarChannelId: calendarChannel.id,
connectedAccountId: connectedChannel.connectedAccountId,
handle,
cleanup: () => deleteConnectedAccount(connectedChannel.connectedAccountId),
};
};

View File

@@ -0,0 +1,96 @@
import { type NestExpressApplication } from '@nestjs/platform-express';
import {
Test,
type TestingModule,
type TestingModuleBuilder,
} from '@nestjs/testing';
import bytes from 'bytes';
import graphqlUploadExpress from 'graphql-upload/graphqlUploadExpress.mjs';
import { AppModule } from 'src/app.module';
import { settings } from 'src/engine/constants/settings';
import { TwentyConfigService } from 'src/engine/core-modules/twenty-config/twenty-config.service';
import { applyCredentialedCors } from 'src/engine/core-modules/user-session/utils/apply-credentialed-cors.util';
import { StripeSDKMockService } from 'src/engine/core-modules/billing/stripe/stripe-sdk/mocks/stripe-sdk-mock.service';
import { StripeSDKService } from 'src/engine/core-modules/billing/stripe/stripe-sdk/services/stripe-sdk.service';
import { CaptchaDriverFactory } from 'src/engine/core-modules/captcha/captcha-driver.factory';
import { ExceptionHandlerService } from 'src/engine/core-modules/exception-handler/exception-handler.service';
import { ExceptionHandlerMockService } from 'src/engine/core-modules/exception-handler/mocks/exception-handler-mock.service';
import { JobsModule } from 'src/engine/core-modules/message-queue/jobs.module';
import { MessageQueueModule } from 'src/engine/core-modules/message-queue/message-queue.module';
interface TestingModuleCreatePreHook {
(moduleBuilder: TestingModuleBuilder): TestingModuleBuilder;
}
/**
* Hook for adding items to nest application
*/
export type TestingAppCreatePreHook = (
app: NestExpressApplication,
) => Promise<void>;
/**
* Sets basic integration testing module of app
*/
export const createApp = async (
config: {
moduleBuilderHook?: TestingModuleCreatePreHook;
appInitHook?: TestingAppCreatePreHook;
} = {},
): Promise<NestExpressApplication> => {
const stripeSDKMockService = new StripeSDKMockService();
const mockExceptionHandlerService = new ExceptionHandlerMockService();
let moduleBuilder: TestingModuleBuilder = Test.createTestingModule({
imports: [AppModule, JobsModule, MessageQueueModule.registerExplorer()],
})
.overrideProvider(StripeSDKService)
.useValue(stripeSDKMockService)
.overrideProvider(ExceptionHandlerService)
.useValue(mockExceptionHandlerService)
.overrideProvider(CaptchaDriverFactory)
.useValue({
getCurrentDriver: () => ({
validate: async () => ({ success: true }),
}),
});
if (config.moduleBuilderHook) {
moduleBuilder = config.moduleBuilderHook(moduleBuilder);
}
const moduleFixture: TestingModule = await moduleBuilder.compile();
const app = moduleFixture.createNestApplication<NestExpressApplication>({
rawBody: true,
});
// The production CORS setup, not the Nest wildcard default, so integration
// tests exercise the credentialed-origin allowlist the deployment runs.
applyCredentialedCors(app, app.get(TwentyConfigService));
app.use(
'/graphql',
graphqlUploadExpress({
maxFieldSize: bytes(settings.storage.maxFileSize)!,
maxFiles: 10,
}),
);
app.use(
'/metadata',
graphqlUploadExpress({
maxFieldSize: bytes(settings.storage.maxFileSize)!,
maxFiles: 10,
}),
);
if (config.appInitHook) {
await config.appInitHook(app);
}
await app.init();
return app;
};

View File

@@ -0,0 +1,50 @@
import gql from 'graphql-tag';
import { makeMetadataAPIRequest } from 'test/integration/metadata/suites/utils/make-metadata-api-request.util';
type CreateCalendarEventInput = {
connectedAccountId: string;
title: string;
description?: string;
location?: string;
startsAt: string;
endsAt: string;
isFullDay?: boolean;
timeZone?: string;
attendees?: string;
sendInvitations?: boolean;
addConferencing?: boolean;
};
type CreateCalendarEventResult = {
success: boolean;
error?: string;
iCalUid?: string;
conferenceLink?: string;
};
const CREATE_CALENDAR_EVENT_MUTATION = gql`
mutation CreateCalendarEventForIntegrationTest(
$input: CreateCalendarEventInput!
) {
createCalendarEvent(input: $input) {
success
error
iCalUid
conferenceLink
}
}
`;
export const createCalendarEvent = async (
input: CreateCalendarEventInput,
): Promise<CreateCalendarEventResult> => {
const response = await makeMetadataAPIRequest({
query: CREATE_CALENDAR_EVENT_MUTATION,
variables: { input },
});
expect(response.body.errors).toBeUndefined();
return response.body.data.createCalendarEvent;
};

View File

@@ -0,0 +1,7 @@
const TEST_SCHEMA_NAME = 'workspace_1wgvd1injqtife6y4rvfbu3h5';
export const deleteAllRecords = async (objectNameSingular: string) => {
await global.testDataSource.query(
`DELETE from "${TEST_SCHEMA_NAME}"."${objectNameSingular}"`,
);
};

View File

@@ -0,0 +1,24 @@
const TEST_SCHEMA_NAME = 'workspace_1wgvd1injqtife6y4rvfbu3h5';
export const deleteRecordsByIds = async (
objectNameSingular: string,
recordIds: string[],
) => {
if (!recordIds.length) {
return;
}
try {
// Create placeholders for parameterized query: $1, $2, $3, etc.
const placeholders = recordIds
.map((_, index) => `$${index + 1}`)
.join(', ');
await global.testDataSource.query(
`DELETE from "${TEST_SCHEMA_NAME}"."${objectNameSingular}" WHERE id IN (${placeholders})`,
recordIds,
);
} catch {
/* empty */
}
};

View File

@@ -0,0 +1,21 @@
import { createTransport } from 'nodemailer';
export const deliverMailOverSmtp = async ({
host,
port,
from,
to,
subject,
}: {
host: string;
port: number;
from: string;
to: string;
subject: string;
}): Promise<void> => {
const transport = createTransport({ host, port, secure: false });
await transport.sendMail({ from, to, subject, text: `body of ${subject}` });
transport.close();
};

View File

@@ -0,0 +1,25 @@
import { type MessageQueueJobData } from 'src/engine/core-modules/message-queue/interfaces/message-queue-job.interface';
import { type MessageQueue } from 'src/engine/core-modules/message-queue/message-queue.constants';
import { type MessageQueueService } from 'src/engine/core-modules/message-queue/services/message-queue.service';
import { getQueueToken } from 'src/engine/core-modules/message-queue/utils/get-queue-token.util';
import { waitForAllJobsToFinish } from 'test/integration/utils/wait-for-all-jobs-to-finish.util';
// Enqueues on the real BullMQ queue and waits for every queue to drain, so
// follow-up jobs the worker chains (imports, contact creation, ...) are also
// done when this resolves. Failure-path suites rely on the job being allowed
// to fail: they assert the resulting channel state afterwards.
export const enqueueJobAndDrain = async <TData extends MessageQueueJobData>(
queue: MessageQueue,
jobName: string,
data: TData,
): Promise<void> => {
const messageQueueService = global.app.get<MessageQueueService>(
getQueueToken(queue),
{ strict: false },
);
await messageQueueService.add(jobName, data);
await waitForAllJobsToFinish();
};

View File

@@ -0,0 +1,25 @@
type ExpectEventuallyOptions = {
timeoutMs?: number;
intervalMs?: number;
};
export const expectEventually = async (
assertion: () => Promise<void> | void,
{ timeoutMs = 10_000, intervalMs = 100 }: ExpectEventuallyOptions = {},
): Promise<void> => {
const startedAt = Date.now();
for (;;) {
try {
await assertion();
return;
} catch (error) {
if (Date.now() - startedAt > timeoutMs) {
throw error;
}
await new Promise((resolve) => setTimeout(resolve, intervalMs));
}
}
};

View File

@@ -0,0 +1,13 @@
// REST counterpart of `expectOneNotInternalServerErrorSnapshot`.
export const expectOneNotInternalServerErrorHttpResponseSnapshot = ({
status,
body,
}: {
status: number;
body: Record<string, unknown>;
}) => {
expect(status).not.toBe(500);
expect(body.code).not.toBe('INTERNAL_SERVER_ERROR');
expect({ status, body }).toMatchSnapshot();
};

View File

@@ -0,0 +1,16 @@
import { findRecordNodesByFilter } from 'test/integration/utils/find-records-by-filter.util';
export const findCalendarEventAssociationChannelIds = async (
eventExternalId: string,
): Promise<string[]> => {
const associations = await findRecordNodesByFilter<{
calendarChannelId: string;
}>(
'calendarChannelEventAssociation',
'calendarChannelEventAssociations',
'calendarChannelId',
{ eventExternalId: { eq: eventExternalId } },
);
return associations.map(({ calendarChannelId }) => calendarChannelId).sort();
};

View File

@@ -0,0 +1,39 @@
import { findRecordNodesByFilter } from 'test/integration/utils/find-records-by-filter.util';
export const findImportedMessageSubjects = async (
subjects: string[],
): Promise<string[]> => {
const messages = await findRecordNodesByFilter<{ subject: string }>(
'message',
'messages',
'subject',
{ subject: { in: subjects } },
);
return messages.map((message) => message.subject).sort();
};
export const findImportedCalendarEventTitles = async (
titles: string[],
): Promise<string[]> => {
const events = await findRecordNodesByFilter<{ title: string }>(
'calendarEvent',
'calendarEvents',
'title',
{ title: { in: titles } },
);
return events.map((event) => event.title).sort();
};
export const findCreatedPeopleEmails = async (
primaryEmails: string[],
): Promise<string[]> => {
const people = await findRecordNodesByFilter<{
emails: { primaryEmail: string };
}>('person', 'people', 'emails { primaryEmail }', {
emails: { primaryEmail: { in: primaryEmails } },
});
return people.map((person) => person.emails.primaryEmail).sort();
};

View File

@@ -0,0 +1,45 @@
import { findManyOperationFactory } from 'test/integration/graphql/utils/find-many-operation-factory.util';
import { makeGraphqlAPIRequest } from 'test/integration/graphql/utils/make-graphql-api-request.util';
export const findRecordNodesByFilter = async <TNode>(
objectMetadataSingularName: string,
objectMetadataPluralName: string,
gqlFields: string,
filter: object,
): Promise<TNode[]> => {
const response = await makeGraphqlAPIRequest(
findManyOperationFactory({
objectMetadataSingularName,
objectMetadataPluralName,
gqlFields,
filter,
}),
);
if (response.body.errors?.length) {
throw new Error(
`findRecordNodesByFilter(${objectMetadataPluralName}) failed: ${response.body.errors
.map((error: { message: string }) => error.message)
.join('; ')}`,
);
}
return response.body.data[objectMetadataPluralName].edges.map(
(edge: { node: TNode }) => edge.node,
);
};
export const findRecordIdsByFilter = async (
objectMetadataSingularName: string,
objectMetadataPluralName: string,
filter: object,
): Promise<string[]> => {
const nodes = await findRecordNodesByFilter<{ id: string }>(
objectMetadataSingularName,
objectMetadataPluralName,
'id',
filter,
);
return nodes.map((node) => node.id);
};

View File

@@ -0,0 +1,4 @@
export const TEST_NAME_PREFIX = 'test_record_';
export const generateRecordName = (uuid: string) =>
`${TEST_NAME_PREFIX}-${uuid}`;

View File

@@ -0,0 +1,24 @@
import gql from 'graphql-tag';
import { makeMetadataAPIRequest } from 'test/integration/metadata/suites/utils/make-metadata-api-request.util';
import { getDataOrThrow } from 'test/integration/utils/query-messaging.util';
export const generateTransientToken = async (): Promise<string> => {
const response = await makeMetadataAPIRequest({
query: gql`
mutation GenerateTransientToken {
generateTransientToken {
transientToken {
token
}
}
}
`,
});
const data = getDataOrThrow(response) as {
generateTransientToken: { transientToken: { token: string } };
};
return data.generateTransientToken.transientToken.token;
};

View File

@@ -0,0 +1,30 @@
type ProviderWrapper = { instance: unknown };
type ContainerModule = { providers: Map<unknown, ProviderWrapper> };
export const getAppProviderByClassName = <T>(className: string): T => {
if (!global.app) {
throw new Error(
'global.app is not set: integration test globalSetup has not run',
);
}
const container = (
global.app as unknown as {
container: { getModules: () => Map<string, ContainerModule> };
}
).container;
for (const containerModule of container.getModules().values()) {
for (const [token, wrapper] of containerModule.providers) {
if (
typeof token === 'function' &&
token.name === className &&
wrapper.instance
) {
return wrapper.instance as T;
}
}
}
throw new Error(`Provider "${className}" not found in application container`);
};

View File

@@ -0,0 +1,11 @@
import { getRepositoryToken } from '@nestjs/typeorm';
import { type EntityClassOrSchema } from '@nestjs/typeorm/dist/interfaces/entity-class-or-schema.type';
import { type ObjectLiteral, type Repository } from 'typeorm';
export const getCoreRepository = <Entity extends ObjectLiteral>(
target: EntityClassOrSchema,
): Repository<Entity> =>
global.app.get<Repository<Entity>>(getRepositoryToken(target), {
strict: false,
});

View File

@@ -0,0 +1,56 @@
import { http, HttpResponse, passthrough, type RequestHandler } from 'msw';
import { setupServer } from 'msw/node';
const localhostPassthroughHandlers = [
http.all('http://127.0.0.1*', () => passthrough()),
http.all('http://localhost*', () => passthrough()),
];
// With onUnhandledRequest: 'error', msw throws an uncaught InternalError inside
// the interceptor and the pending request never settles, which hangs the caller
// forever (jest swallows the exception). Responding 500 instead makes the caller
// fail immediately with an attributable error.
const unmockedRequestCatchAllHandler = http.all('*', ({ request }) => {
const message = `Unmocked external request in integration test: ${request.method} ${request.url}`;
console.error(message);
return HttpResponse.json({ error: { message } }, { status: 500 });
});
const server = setupServer(
...localhostPassthroughHandlers,
unmockedRequestCatchAllHandler,
);
export type MswHandler = RequestHandler;
export type HttpMock = {
use: (...handlers: MswHandler[]) => void;
};
export const setupHttpMock = (...baseHandlers: MswHandler[]): HttpMock => {
const applyBaseHandlers = () => {
if (baseHandlers.length > 0) {
server.use(...baseHandlers);
}
};
beforeAll(() => {
server.listen({ onUnhandledRequest: 'error' });
applyBaseHandlers();
});
afterEach(() => {
server.resetHandlers();
applyBaseHandlers();
});
afterAll(() => {
server.close();
});
return {
use: (...handlers: MswHandler[]) => server.use(...handlers),
};
};

View File

@@ -0,0 +1,27 @@
export type MockEntityStore<TEntity> = {
add: (entity: TEntity) => void;
remove: (entityId: string) => void;
reset: () => void;
list: () => TEntity[];
};
export const createMockEntityStore = <TEntity>(
initialEntities: TEntity[],
getEntityId: (entity: TEntity) => string,
): MockEntityStore<TEntity> => {
const seedEntities = [...initialEntities];
let entities = [...seedEntities];
return {
add: (entity) => {
entities = [...entities, entity];
},
remove: (entityId) => {
entities = entities.filter((entity) => getEntityId(entity) !== entityId);
},
reset: () => {
entities = [...seedEntities];
},
list: () => [...entities],
};
};

View File

@@ -0,0 +1,315 @@
import gql from 'graphql-tag';
import {
type CalendarChannelContactAutoCreationPolicy,
type MessageFolderImportPolicy,
} from 'twenty-shared/types';
import { type CalendarChannelDTO } from 'src/engine/metadata-modules/calendar-channel/dtos/calendar-channel.dto';
import { type ConnectedAccountDTO } from 'src/engine/metadata-modules/connected-account/dtos/connected-account.dto';
import { type MessageChannelDTO } from 'src/engine/metadata-modules/message-channel/dtos/message-channel.dto';
import { type MessageFolderDTO } from 'src/engine/metadata-modules/message-folder/dtos/message-folder.dto';
import { makeMetadataAPIRequest } from 'test/integration/metadata/suites/utils/make-metadata-api-request.util';
import { waitForAllJobsToFinish } from 'test/integration/utils/wait-for-all-jobs-to-finish.util';
type MetadataAPIResponse = {
body: { data: Record<string, unknown>; errors?: { message: string }[] };
};
export const getDataOrThrow = (response: MetadataAPIResponse) => {
if (response.body.errors?.length) {
throw new Error(
`Metadata API request failed: ${response.body.errors
.map((error) => error.message)
.join('; ')}`,
);
}
return response.body.data;
};
export type MessageFolderDto = Pick<
MessageFolderDTO,
'id' | 'name' | 'isSynced' | 'isSentFolder' | 'pendingSyncAction'
>;
export type MessageChannelDto = Pick<
MessageChannelDTO,
| 'id'
| 'handle'
| 'connectedAccountId'
| 'syncStatus'
| 'syncStage'
| 'syncStageStartedAt'
| 'throttleFailureCount'
| 'throttleRetryAfter'
| 'visibility'
>;
export type CalendarChannelDto = Pick<
CalendarChannelDTO,
| 'id'
| 'handle'
| 'connectedAccountId'
| 'syncStatus'
| 'syncStage'
| 'syncStageStartedAt'
| 'throttleFailureCount'
| 'isContactAutoCreationEnabled'
| 'contactAutoCreationPolicy'
| 'visibility'
>;
export type ConnectedAccountDto = Pick<
ConnectedAccountDTO,
'id' | 'handle' | 'provider' | 'lastCredentialsRefreshedAt' | 'authFailedAt'
>;
type MessageChannelUpdate = {
messageFolderImportPolicy?: MessageFolderImportPolicy;
isSyncEnabled?: boolean;
isContactAutoCreationEnabled?: boolean;
};
type CalendarChannelUpdate = {
isSyncEnabled?: boolean;
isContactAutoCreationEnabled?: boolean;
contactAutoCreationPolicy?: CalendarChannelContactAutoCreationPolicy;
};
const MESSAGE_CHANNEL_FIELDS = gql`
fragment TestMessageChannelFields on MessageChannel {
id
handle
connectedAccountId
syncStatus
syncStage
syncStageStartedAt
throttleFailureCount
throttleRetryAfter
visibility
}
`;
export const queryMessageChannels = async (): Promise<MessageChannelDto[]> => {
const response = await makeMetadataAPIRequest({
query: gql`
query MessageChannelsForTest {
myMessageChannels {
...TestMessageChannelFields
}
}
${MESSAGE_CHANNEL_FIELDS}
`,
});
return getDataOrThrow(response).myMessageChannels as MessageChannelDto[];
};
export const queryMessageChannel = async ({
connectedAccountId,
channelId,
}: {
connectedAccountId: string;
channelId: string;
}): Promise<MessageChannelDto> => {
const response = await makeMetadataAPIRequest({
query: gql`
query MessageChannelForTest($connectedAccountId: UUID) {
myMessageChannels(connectedAccountId: $connectedAccountId) {
...TestMessageChannelFields
}
}
${MESSAGE_CHANNEL_FIELDS}
`,
variables: { connectedAccountId },
});
const channel = (
getDataOrThrow(response).myMessageChannels as MessageChannelDto[]
).find((candidate) => candidate.id === channelId);
if (!channel) {
throw new Error(`Message channel ${channelId} not found`);
}
return channel;
};
export const queryMessageFolders = async (
messageChannelId: string,
): Promise<MessageFolderDto[]> => {
const response = await makeMetadataAPIRequest({
query: gql`
query MessageFoldersForTest($messageChannelId: UUID) {
myMessageFolders(messageChannelId: $messageChannelId) {
id
name
isSynced
isSentFolder
pendingSyncAction
}
}
`,
variables: { messageChannelId },
});
return getDataOrThrow(response).myMessageFolders as MessageFolderDto[];
};
export const queryCalendarChannels = async (
connectedAccountId: string,
): Promise<CalendarChannelDto[]> => {
const response = await makeMetadataAPIRequest({
query: gql`
query CalendarChannelsForTest($connectedAccountId: UUID) {
myCalendarChannels(connectedAccountId: $connectedAccountId) {
id
handle
connectedAccountId
syncStatus
syncStage
syncStageStartedAt
throttleFailureCount
isContactAutoCreationEnabled
contactAutoCreationPolicy
visibility
}
}
`,
variables: { connectedAccountId },
});
return getDataOrThrow(response).myCalendarChannels as CalendarChannelDto[];
};
export const queryCalendarChannel = async ({
connectedAccountId,
calendarChannelId,
}: {
connectedAccountId: string;
calendarChannelId: string;
}): Promise<CalendarChannelDto> => {
const channel = (await queryCalendarChannels(connectedAccountId)).find(
(candidate) => candidate.id === calendarChannelId,
);
if (!channel) {
throw new Error(`Calendar channel ${calendarChannelId} not found`);
}
return channel;
};
export const queryConnectedAccount = async (
connectedAccountId: string,
): Promise<ConnectedAccountDto> => {
const response = await makeMetadataAPIRequest({
query: gql`
query ConnectedAccountsForTest {
myConnectedAccounts {
id
handle
provider
lastCredentialsRefreshedAt
authFailedAt
}
}
`,
});
const account = (
getDataOrThrow(response).myConnectedAccounts as ConnectedAccountDto[]
).find((candidate) => candidate.id === connectedAccountId);
if (!account) {
throw new Error(`Connected account ${connectedAccountId} not found`);
}
return account;
};
export const updateMessageChannel = async (
messageChannelId: string,
update: MessageChannelUpdate,
): Promise<void> => {
const response = await makeMetadataAPIRequest({
query: gql`
mutation UpdateMessageChannelForTest($input: UpdateMessageChannelInput!) {
updateMessageChannel(input: $input) {
id
}
}
`,
variables: {
input: {
id: messageChannelId,
update,
},
},
});
getDataOrThrow(response);
};
export const updateCalendarChannel = async (
calendarChannelId: string,
update: CalendarChannelUpdate,
): Promise<void> => {
const response = await makeMetadataAPIRequest({
query: gql`
mutation UpdateCalendarChannelForTest(
$input: UpdateCalendarChannelInput!
) {
updateCalendarChannel(input: $input) {
id
}
}
`,
variables: {
input: {
id: calendarChannelId,
update,
},
},
});
getDataOrThrow(response);
};
export const startChannelSync = async (
connectedAccountId: string,
): Promise<void> => {
const response = await makeMetadataAPIRequest({
query: gql`
mutation StartChannelSyncForTest($connectedAccountId: UUID!) {
startChannelSync(connectedAccountId: $connectedAccountId) {
success
}
}
`,
variables: { connectedAccountId },
});
getDataOrThrow(response);
};
export const deleteConnectedAccount = async (
connectedAccountId: string,
): Promise<void> => {
const response = await makeMetadataAPIRequest({
query: gql`
mutation DeleteConnectedAccountForTest($id: UUID!) {
deleteConnectedAccount(id: $id) {
id
}
}
`,
variables: { id: connectedAccountId },
});
getDataOrThrow(response);
await waitForAllJobsToFinish();
};

View File

@@ -0,0 +1,2 @@
export const raiseSqlState = (sqlState: string): string =>
`DO $$ BEGIN RAISE EXCEPTION 'simulated database failure' USING ERRCODE = '${sqlState}'; END $$;`;

View File

@@ -0,0 +1,15 @@
import { WorkspaceEntity } from 'src/engine/core-modules/workspace/workspace.entity';
import { getCoreRepository } from 'test/integration/utils/get-core-repository.util';
export const readBackendState = async (
backendPid: number,
): Promise<string | undefined> => {
const activities: { state: string }[] = await getCoreRepository<WorkspaceEntity>(
WorkspaceEntity,
).manager.query('SELECT state FROM pg_stat_activity WHERE pid = $1', [
backendPid,
]);
return activities[0]?.state;
};

View File

@@ -0,0 +1,44 @@
import {
CalendarChannelSyncStage,
CalendarChannelSyncStatus,
MessageChannelSyncStage,
MessageChannelSyncStatus,
} 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 { getCoreRepository } from 'test/integration/utils/get-core-repository.util';
export const resetMessageChannelSyncState = async (
messageChannelId: string,
syncCursor = 'reset-cursor',
): Promise<void> => {
await getCoreRepository<MessageChannelEntity>(MessageChannelEntity).update(
{ id: messageChannelId },
{
syncStatus: MessageChannelSyncStatus.ACTIVE,
syncStage: MessageChannelSyncStage.MESSAGE_LIST_FETCH_PENDING,
syncStageStartedAt: null,
throttleFailureCount: 0,
throttleRetryAfter: null,
syncCursor,
},
);
};
export const resetCalendarChannelSyncState = async (
calendarChannelId: string,
syncCursor = 'reset-cursor',
): Promise<void> => {
await getCoreRepository<CalendarChannelEntity>(CalendarChannelEntity).update(
{ id: calendarChannelId },
{
syncStatus: CalendarChannelSyncStatus.ACTIVE,
syncStage: CalendarChannelSyncStage.CALENDAR_EVENT_LIST_FETCH_PENDING,
syncStageStartedAt: null,
throttleFailureCount: 0,
syncCursor,
},
);
};

View File

@@ -0,0 +1,24 @@
import { CalendarChannelSyncStage } from 'twenty-shared/types';
import { MessageQueue } from 'src/engine/core-modules/message-queue/message-queue.constants';
import { CalendarChannelEntity } from 'src/engine/metadata-modules/calendar-channel/entities/calendar-channel.entity';
import { CalendarEventsImportJob } from 'src/modules/calendar/calendar-event-import-manager/jobs/calendar-events-import.job';
import { enqueueJobAndDrain } from 'test/integration/utils/enqueue-job-and-drain.util';
import { scheduleChannelStage } from 'test/integration/utils/schedule-channel-stage.util';
export const runCalendarChannelEventsImport = async (
calendarChannelId: string,
): Promise<void> => {
const workspaceId = await scheduleChannelStage(
CalendarChannelEntity,
calendarChannelId,
CalendarChannelSyncStage.CALENDAR_EVENTS_IMPORT_SCHEDULED,
);
await enqueueJobAndDrain(
MessageQueue.calendarQueue,
CalendarEventsImportJob.name,
{ workspaceId, calendarChannelId },
);
};

View File

@@ -0,0 +1,24 @@
import { CalendarChannelSyncStage } from 'twenty-shared/types';
import { MessageQueue } from 'src/engine/core-modules/message-queue/message-queue.constants';
import { CalendarChannelEntity } from 'src/engine/metadata-modules/calendar-channel/entities/calendar-channel.entity';
import { CalendarEventListFetchJob } from 'src/modules/calendar/calendar-event-import-manager/jobs/calendar-event-list-fetch.job';
import { enqueueJobAndDrain } from 'test/integration/utils/enqueue-job-and-drain.util';
import { scheduleChannelStage } from 'test/integration/utils/schedule-channel-stage.util';
export const runCalendarChannelListFetch = async (
calendarChannelId: string,
): Promise<void> => {
const workspaceId = await scheduleChannelStage(
CalendarChannelEntity,
calendarChannelId,
CalendarChannelSyncStage.CALENDAR_EVENT_LIST_FETCH_SCHEDULED,
);
await enqueueJobAndDrain(
MessageQueue.calendarQueue,
CalendarEventListFetchJob.name,
{ workspaceId, calendarChannelId },
);
};

View File

@@ -0,0 +1,25 @@
import { CalendarChannelSyncStage } from 'twenty-shared/types';
import { MessageQueue } from 'src/engine/core-modules/message-queue/message-queue.constants';
import { CalendarChannelEntity } from 'src/engine/metadata-modules/calendar-channel/entities/calendar-channel.entity';
import { CalendarEventWebhookSyncJob } from 'src/modules/connected-account-sync-webhooks/calendar-event-webhook-sync/jobs/calendar-event-webhook-sync.job';
import { enqueueJobAndDrain } from 'test/integration/utils/enqueue-job-and-drain.util';
import { scheduleChannelStage } from 'test/integration/utils/schedule-channel-stage.util';
export const runCalendarChannelWebhookSync = async (
calendarChannelId: string,
startingStage: CalendarChannelSyncStage = CalendarChannelSyncStage.CALENDAR_EVENT_LIST_FETCH_PENDING,
): Promise<void> => {
const workspaceId = await scheduleChannelStage(
CalendarChannelEntity,
calendarChannelId,
startingStage,
);
await enqueueJobAndDrain(
MessageQueue.connectedAccountSyncWebhookQueue,
CalendarEventWebhookSyncJob.name,
{ workspaceId, calendarChannelId },
);
};

View File

@@ -0,0 +1,136 @@
import { MessageFolderPendingSyncAction } from 'twenty-shared/types';
import { MessageChannelEntity } from 'src/engine/metadata-modules/message-channel/entities/message-channel.entity';
import { MessageFolderEntity } from 'src/engine/metadata-modules/message-folder/entities/message-folder.entity';
import { type MessagingImportFolderMessagesService } from 'src/modules/messaging/message-import-manager/services/messaging-import-folder-messages.service';
import { type MessagingProcessFolderActionsService } from 'src/modules/messaging/message-import-manager/services/messaging-process-folder-actions.service';
import { getAppProviderByClassName } from 'test/integration/utils/get-app-provider-by-class-name.util';
import { getCoreRepository } from 'test/integration/utils/get-core-repository.util';
export const MESSAGE_COUNT_ABOVE_V8_SPREAD_ARGUMENT_LIMIT = 200000;
export const FOLDER_ACTION_NAMES = {
imported: 'Large import folder',
failing: 'Failing import folder',
untouched: 'Untouched folder',
};
const MESSAGE_EXTERNAL_IDS = Array.from(
{ length: MESSAGE_COUNT_ABOVE_V8_SPREAD_ARGUMENT_LIMIT },
(_, index) => `large-folder-message-${index}`,
);
const getMessageFolderRepository = () =>
getCoreRepository<MessageFolderEntity>(MessageFolderEntity);
const getMessageChannel = (messageChannelId: string) =>
getCoreRepository<MessageChannelEntity>(MessageChannelEntity).findOneByOrFail(
{ id: messageChannelId },
);
const processFolderActions = (
messageChannel: MessageChannelEntity,
messageFolders: MessageFolderEntity[],
) =>
getAppProviderByClassName<MessagingProcessFolderActionsService>(
'MessagingProcessFolderActionsService',
).processFolderActions(
messageChannel,
messageFolders,
messageChannel.workspaceId,
);
export const runFolderActions = async ({
messageChannelId,
folderNameToDelete,
}: {
messageChannelId: string;
folderNameToDelete: string;
}): Promise<string[]> => {
const messageChannel = await getMessageChannel(messageChannelId);
const messageFolderRepository = getMessageFolderRepository();
const buildFolder = (
name: string,
pendingSyncAction: MessageFolderPendingSyncAction,
) => ({
messageChannelId,
workspaceId: messageChannel.workspaceId,
name,
externalId: `${name}-external-id`,
isSentFolder: false,
isSynced: true,
pendingSyncAction,
});
await messageFolderRepository.save([
buildFolder(
FOLDER_ACTION_NAMES.imported,
MessageFolderPendingSyncAction.FOLDER_IMPORT,
),
buildFolder(
FOLDER_ACTION_NAMES.failing,
MessageFolderPendingSyncAction.FOLDER_IMPORT,
),
buildFolder(
FOLDER_ACTION_NAMES.untouched,
MessageFolderPendingSyncAction.NONE,
),
]);
const folderToDelete = await messageFolderRepository.findOneByOrFail({
messageChannelId,
name: folderNameToDelete,
});
await messageFolderRepository.update(
{ id: folderToDelete.id },
{ pendingSyncAction: MessageFolderPendingSyncAction.FOLDER_DELETION },
);
const failingFolder = await messageFolderRepository.findOneByOrFail({
messageChannelId,
name: FOLDER_ACTION_NAMES.failing,
});
jest
.spyOn(
getAppProviderByClassName<MessagingImportFolderMessagesService>(
'MessagingImportFolderMessagesService',
),
'getFolderMessageIdsToImport',
)
.mockImplementation(async (_messageChannel, messageFolder) => {
if (messageFolder.id === failingFolder.id) {
throw new Error('Folder import failed');
}
return [...MESSAGE_EXTERNAL_IDS, MESSAGE_EXTERNAL_IDS[0]];
});
const { messageExternalIdsToImport } = await processFolderActions(
messageChannel,
await messageFolderRepository.findBy({ messageChannelId }),
);
return messageExternalIdsToImport;
};
export const runFolderActionsWithoutPendingActions = async (
messageChannelId: string,
): Promise<string[]> => {
const messageChannel = await getMessageChannel(messageChannelId);
const untouchedFolder = await getMessageFolderRepository().findOneByOrFail({
messageChannelId,
name: FOLDER_ACTION_NAMES.untouched,
});
const { messageExternalIdsToImport } = await processFolderActions(
messageChannel,
[untouchedFolder],
);
return messageExternalIdsToImport;
};

View File

@@ -0,0 +1,24 @@
import { MessageChannelSyncStage } 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 { MessagingMessageListFetchJob } from 'src/modules/messaging/message-import-manager/jobs/messaging-message-list-fetch.job';
import { enqueueJobAndDrain } from 'test/integration/utils/enqueue-job-and-drain.util';
import { scheduleChannelStage } from 'test/integration/utils/schedule-channel-stage.util';
export const runMessageChannelSync = async (
messageChannelId: string,
): Promise<void> => {
const workspaceId = await scheduleChannelStage(
MessageChannelEntity,
messageChannelId,
MessageChannelSyncStage.MESSAGE_LIST_FETCH_SCHEDULED,
);
await enqueueJobAndDrain(
MessageQueue.messagingQueue,
MessagingMessageListFetchJob.name,
{ workspaceId, messageChannelId },
);
};

View File

@@ -0,0 +1,9 @@
import { MessageQueue } from 'src/engine/core-modules/message-queue/message-queue.constants';
import { enqueueJobAndDrain } from 'test/integration/utils/enqueue-job-and-drain.util';
export const runSyncCron = async (cronJob: {
name: string;
}): Promise<void> => {
await enqueueJobAndDrain(MessageQueue.cronQueue, cronJob.name, {});
};

View File

@@ -0,0 +1,29 @@
import { type EntityClassOrSchema } from '@nestjs/typeorm/dist/interfaces/entity-class-or-schema.type';
import {
type CalendarChannelSyncStage,
type MessageChannelSyncStage,
} from 'twenty-shared/types';
import { getCoreRepository } from 'test/integration/utils/get-core-repository.util';
// The sync crons scan every active workspace's channels, so triggering them from
// a test fans out across co-located suites. The per-channel triggers instead
// drive a SINGLE channel through its leaf job: this sets the stage the leaf job
// guards on and returns the workspaceId the job payload needs.
export const scheduleChannelStage = async (
channelEntity: EntityClassOrSchema,
channelId: string,
scheduledStage: MessageChannelSyncStage | CalendarChannelSyncStage,
): Promise<string> => {
const repository = getCoreRepository<{
id: string;
workspaceId: string;
syncStage: MessageChannelSyncStage | CalendarChannelSyncStage;
}>(channelEntity);
const channel = await repository.findOneByOrFail({ id: channelId });
await repository.update({ id: channelId }, { syncStage: scheduledStage });
return channel.workspaceId;
};

View File

@@ -0,0 +1,44 @@
import gql from 'graphql-tag';
import { makeMetadataAPIRequest } from 'test/integration/metadata/suites/utils/make-metadata-api-request.util';
type SendEmailInput = {
connectedAccountId: string;
fromHandle?: string;
to: string;
cc?: string;
bcc?: string;
subject: string;
body: string;
inReplyTo?: string;
draftMessageId?: string;
};
type SendEmailResult = {
success: boolean;
error?: string;
messageThreadId?: string;
};
const SEND_EMAIL_MUTATION = gql`
mutation SendEmailForIntegrationTest($input: SendEmailInput!) {
sendEmail(input: $input) {
success
error
messageThreadId
}
}
`;
export const sendEmail = async (
input: SendEmailInput,
): Promise<SendEmailResult> => {
const response = await makeMetadataAPIRequest({
query: SEND_EMAIL_MUTATION,
variables: { input },
});
expect(response.body.errors).toBeUndefined();
return response.body.data.sendEmail;
};

View File

@@ -0,0 +1,15 @@
import { ConnectedAccountEntity } from 'src/engine/metadata-modules/connected-account/entities/connected-account.entity';
import { getCoreRepository } from 'test/integration/utils/get-core-repository.util';
export const setTestConnectedAccountHandleAliases = async ({
connectedAccountId,
handleAliases,
}: {
connectedAccountId: string;
handleAliases: string[];
}): Promise<void> => {
await getCoreRepository<ConnectedAccountEntity>(
ConnectedAccountEntity,
).update({ id: connectedAccountId }, { handleAliases });
};

View File

@@ -0,0 +1,26 @@
import nodeFetch from 'node-fetch';
import { type JestConfigWithTsJest } from 'ts-jest';
import 'tsconfig-paths/register';
import { rawDataSource } from 'src/database/typeorm/raw/raw.datasource';
import { createApp } from './create-app';
export default async (_: unknown, projectConfig: JestConfigWithTsJest) => {
// node-fetch rides node:http, which msw patches; native undici fetch
// escapes interception.
globalThis.fetch = nodeFetch as unknown as typeof globalThis.fetch;
const app = await createApp({});
if (!projectConfig.globals) {
throw new Error('No globals found in project config');
}
await rawDataSource.initialize();
await app.listen(projectConfig.globals.APP_PORT as number);
global.app = app;
global.testDataSource = rawDataSource;
};

View File

@@ -0,0 +1,19 @@
import {
closeQueueConnections,
waitForAllJobsToFinish,
} from 'test/integration/utils/wait-for-all-jobs-to-finish.util';
const WAIT_FOR_JOBS_HOOK_TIMEOUT_MS = 150_000;
beforeAll(async () => {
await waitForAllJobsToFinish();
}, WAIT_FOR_JOBS_HOOK_TIMEOUT_MS);
afterEach(async () => {
jest.useRealTimers();
await waitForAllJobsToFinish();
}, WAIT_FOR_JOBS_HOOK_TIMEOUT_MS);
afterAll(async () => {
await closeQueueConnections();
}, WAIT_FOR_JOBS_HOOK_TIMEOUT_MS);

View File

@@ -0,0 +1,76 @@
import { createServer, request, type Server } from 'node:http';
import { type AddressInfo } from 'node:net';
// The self-closing form has to be matched on its own: sharing a branch with the
// open tag lets a self-closing occurrence pair up with a later closing tag and
// swallow every response in between.
const COMPONENT_SET_ELEMENT =
/<(\w+:)?supported-calendar-component-set\b[^>]*\/>|<(\w+:)?supported-calendar-component-set\b[^>]*>[\s\S]*?<\/(\w+:)?supported-calendar-component-set>/g;
export type CalDavProxy = {
url: string;
stop: () => Promise<void>;
};
// Radicale always publishes supported-calendar-component-set, so reproducing an
// RFC 4791 server that leaves it out means stripping it off the wire.
export const startCalDavProxyOmittingComponentSet = async ({
targetHost,
targetPort,
}: {
targetHost: string;
targetPort: number;
}): Promise<CalDavProxy> => {
const server: Server = createServer((incoming, outgoing) => {
const requestChunks: Buffer[] = [];
incoming.on('data', (chunk) => requestChunks.push(chunk));
incoming.on('end', () => {
const proxied = request(
{
host: targetHost,
port: targetPort,
path: incoming.url,
method: incoming.method,
headers: {
...incoming.headers,
host: `${targetHost}:${targetPort}`,
'accept-encoding': 'identity',
},
},
(upstream) => {
const responseChunks: Buffer[] = [];
upstream.on('data', (chunk) => responseChunks.push(chunk));
upstream.on('end', () => {
const body = Buffer.concat(responseChunks)
.toString('utf-8')
.replace(COMPONENT_SET_ELEMENT, '');
const {
'content-length': _staleContentLength,
'transfer-encoding': _staleTransferEncoding,
...headers
} = upstream.headers;
outgoing.writeHead(upstream.statusCode ?? 502, {
...headers,
'content-length': Buffer.byteLength(body),
});
outgoing.end(body);
});
},
);
proxied.on('error', () => outgoing.destroy());
proxied.end(Buffer.concat(requestChunks));
});
});
await new Promise<void>((resolve) => server.listen(0, '127.0.0.1', resolve));
return {
url: `http://127.0.0.1:${(server.address() as AddressInfo).port}`,
stop: () => new Promise<void>((resolve) => server.close(() => resolve())),
};
};

View File

@@ -0,0 +1,9 @@
import { startChannelSync } from 'test/integration/utils/query-messaging.util';
import { waitForAllJobsToFinish } from 'test/integration/utils/wait-for-all-jobs-to-finish.util';
export const startChannelSyncAndAwait = async (
connectedAccountId: string,
): Promise<void> => {
await startChannelSync(connectedAccountId);
await waitForAllJobsToFinish();
};

View File

@@ -0,0 +1,54 @@
import {
GenericContainer,
type StartedTestContainer,
Wait,
} from 'testcontainers';
const DOVECOT_IMAGE =
'dovecot/dovecot:2.4.4@sha256:723e3392fe16c6fad8ddc605ea767cc01b4bad9cd9f13eb1dbac15e79c89b2d4';
const DOVECOT_IMAP_PORT = 31143;
export type DovecotServer = {
host: string;
imapPort: number;
stop: () => Promise<void>;
};
// Unlike GreenMail, Dovecot advertises CONDSTORE, QRESYNC and SPECIAL-USE, and
// scopes HIGHESTMODSEQ per mailbox as RFC 7162 requires, so it is the reference
// server for the incremental sync path. Its static passdb accepts any username
// with USER_PASSWORD.
// Cleartext auth has to be switched on and SSL switched off because these tests
// connect without TLS: while STARTTLS is advertised ImapFlow upgrades to it and
// then rejects the image's self-signed certificate.
const DOVECOT_TEST_CONFIG = ['auth_allow_cleartext = yes', 'ssl = no', ''].join(
'\n',
);
export const startDovecotContainer = async ({
password,
}: {
password: string;
}): Promise<DovecotServer> => {
const container: StartedTestContainer = await new GenericContainer(
DOVECOT_IMAGE,
)
.withEnvironment({ USER_PASSWORD: `{PLAIN}${password}` })
.withCopyContentToContainer([
{
content: DOVECOT_TEST_CONFIG,
target: '/etc/dovecot/conf.d/99-integration-test.conf',
},
])
.withExposedPorts(DOVECOT_IMAP_PORT)
.withWaitStrategy(Wait.forListeningPorts())
.start();
return {
host: container.getHost(),
imapPort: container.getMappedPort(DOVECOT_IMAP_PORT),
stop: async () => {
await container.stop();
},
};
};

View File

@@ -0,0 +1,53 @@
import {
GenericContainer,
type StartedTestContainer,
Wait,
} from 'testcontainers';
const GREENMAIL_IMAGE =
'greenmail/standalone:2.1.9@sha256:3ac5a83dd6727cf95e4d50e18907fb8ee7bbf5f67e8534714dee2fb1b5b2e1d4';
const GREENMAIL_IMAP_PORT = 3143;
const GREENMAIL_SMTP_PORT = 3025;
export type GreenmailServer = {
host: string;
imapPort: number;
smtpPort: number;
stop: () => Promise<void>;
};
// The mailbox has to be declared up front: the account save authenticates
// against IMAP immediately, before any mail exists to create a user implicitly.
export const startGreenmailContainer = async ({
username,
password,
}: {
username: string;
password: string;
}): Promise<GreenmailServer> => {
const [localPart, domain] = username.split('@');
const container: StartedTestContainer = await new GenericContainer(
GREENMAIL_IMAGE,
)
.withEnvironment({
GREENMAIL_OPTS: [
'-Dgreenmail.setup.test.imap',
'-Dgreenmail.setup.test.smtp',
'-Dgreenmail.hostname=0.0.0.0',
`-Dgreenmail.users=${localPart}:${password}@${domain}`,
].join(' '),
})
.withExposedPorts(GREENMAIL_IMAP_PORT, GREENMAIL_SMTP_PORT)
.withWaitStrategy(Wait.forListeningPorts())
.start();
return {
host: container.getHost(),
imapPort: container.getMappedPort(GREENMAIL_IMAP_PORT),
smtpPort: container.getMappedPort(GREENMAIL_SMTP_PORT),
stop: async () => {
await container.stop();
},
};
};

View File

@@ -0,0 +1,159 @@
import { once } from 'node:events';
import { createServer, request, type IncomingMessage } from 'node:http';
import { type AddressInfo } from 'node:net';
import { brotliDecompressSync, gunzipSync, inflateSync } from 'node:zlib';
const PROXY_HOST = '127.0.0.1';
const ENTITY_TAG_PATTERN =
/<((?:[\w.-]+:)?)getetag(?:\s[^>]*)?>[\s\S]*?<\/\1getetag>/g;
const LAST_MODIFIED_PATTERN =
/<((?:[\w.-]+:)?)getlastmodified(?:\s[^>]*)?>[\s\S]*?<\/\1getlastmodified>/g;
const SUPPORTED_REPORT_PATTERN =
/<((?:[\w.-]+:)?)supported-report>[\s\S]*?<\/\1supported-report>/g;
const RESPONSE_PATTERN = /<((?:[\w.-]+:)?)response>[\s\S]*?<\/\1response>/g;
const HREF_PATTERN = /<(?:[\w.-]+:)?href>\s*([^<]*?)\s*<\/(?:[\w.-]+:)?href>/;
export type NonCompliantCalDavProxy = {
host: string;
port: number;
degradations: {
blankLastModified: boolean;
hideCollectionMembers: boolean;
};
stop: () => Promise<void>;
};
const decodeBody = (body: Buffer, contentEncoding: string): Buffer => {
if (body.length === 0) {
return body;
}
switch (contentEncoding) {
case 'gzip':
return gunzipSync(body);
case 'deflate':
return inflateSync(body);
case 'br':
return brotliDecompressSync(body);
default:
return body;
}
};
const readBody = async (message: IncomingMessage): Promise<Buffer> => {
const chunks: Buffer[] = [];
for await (const chunk of message) {
chunks.push(chunk as Buffer);
}
return Buffer.concat(chunks);
};
const blankEntityTags = (body: string): string =>
body.replace(ENTITY_TAG_PATTERN, '<$1getetag />');
const blankLastModified = (body: string): string =>
body.replace(LAST_MODIFIED_PATTERN, '<$1getlastmodified />');
const dropSyncCollectionReport = (body: string): string =>
body.replace(SUPPORTED_REPORT_PATTERN, (report) =>
report.includes('sync-collection') ? '' : report,
);
const dropMemberResponses = (body: string): string =>
body.replace(RESPONSE_PATTERN, (response) =>
HREF_PATTERN.exec(response)?.[1].endsWith('/') ? response : '',
);
export const startNonCompliantCalDavProxy = async ({
targetHost,
targetPort,
}: {
targetHost: string;
targetPort: number;
}): Promise<NonCompliantCalDavProxy> => {
const degradations = {
blankLastModified: false,
hideCollectionMembers: false,
};
const server = createServer((incoming, outgoing) => {
void readBody(incoming)
.then((requestBody) => {
const upstream = request(
{
host: targetHost,
port: targetPort,
method: incoming.method,
path: incoming.url,
headers: {
...incoming.headers,
host: `${targetHost}:${targetPort}`,
'accept-encoding': 'identity',
},
},
(upstreamResponse) => {
void readBody(upstreamResponse)
.then((responseBody) => {
const headers = { ...upstreamResponse.headers };
const contentEncoding = String(
headers['content-encoding'] ?? '',
);
delete headers['content-length'];
delete headers['transfer-encoding'];
delete headers['content-encoding'];
const decodedBody = decodeBody(responseBody, contentEncoding);
const isXml = String(headers['content-type'] ?? '').includes(
'xml',
);
const rewrites = [blankEntityTags, dropSyncCollectionReport];
if (degradations.blankLastModified) {
rewrites.push(blankLastModified);
}
if (
degradations.hideCollectionMembers &&
incoming.method === 'PROPFIND'
) {
rewrites.push(dropMemberResponses);
}
outgoing.writeHead(upstreamResponse.statusCode ?? 502, headers);
outgoing.end(
isXml
? rewrites.reduce(
(body, rewrite) => rewrite(body),
decodedBody.toString('utf8'),
)
: decodedBody,
);
})
.catch(() => outgoing.destroy());
},
);
upstream.on('error', () => outgoing.destroy());
upstream.end(requestBody);
})
.catch(() => outgoing.destroy());
});
server.listen(0, PROXY_HOST);
await once(server, 'listening');
return {
host: PROXY_HOST,
port: (server.address() as AddressInfo).port,
degradations,
stop: async () => {
server.closeAllConnections();
server.close();
await once(server, 'close');
},
};
};

View File

@@ -0,0 +1,60 @@
import {
GenericContainer,
type StartedTestContainer,
Wait,
} from 'testcontainers';
const RADICALE_IMAGE =
'tomsquest/docker-radicale:3.5.7.0@sha256:ed0bc36eb284ae7ad6e81d0f00b4f477c025e3ed22e780ca683a80b35102a66e';
const RADICALE_PORT = 5232;
export type RadicaleServer = {
host: string;
port: number;
stop: () => Promise<void>;
};
const RADICALE_BINARY = '/venv/bin/radicale';
const RADICALE_USERS_FILE = '/config/users';
// Credentials are required rather than open: Radicale derives the principal
// collection from the authenticated user, so an anonymous session resolves to
// no calendars at all.
export const startRadicaleContainer = async ({
username,
password,
}: {
username: string;
password: string;
}): Promise<RadicaleServer> => {
const container: StartedTestContainer = await new GenericContainer(
RADICALE_IMAGE,
)
.withCopyContentToContainer([
{ content: `${username}:${password}\n`, target: RADICALE_USERS_FILE },
])
.withCommand([
RADICALE_BINARY,
'--server-hosts',
`0.0.0.0:${RADICALE_PORT}`,
'--auth-type',
'htpasswd',
'--auth-htpasswd-filename',
RADICALE_USERS_FILE,
'--auth-htpasswd-encryption',
'plain',
'--storage-filesystem-folder',
'/data/collections',
])
.withExposedPorts(RADICALE_PORT)
.withWaitStrategy(Wait.forListeningPorts())
.start();
return {
host: container.getHost(),
port: container.getMappedPort(RADICALE_PORT),
stop: async () => {
await container.stop();
},
};
};

View File

@@ -0,0 +1,6 @@
import 'tsconfig-paths/register';
export default async () => {
await global.app.close();
await global.testDataSource.destroy();
};

View File

@@ -0,0 +1,128 @@
import { ViewFilterGroupLogicalOperator } from 'twenty-shared/types';
import { type ViewFieldGroupDTO } from 'src/engine/metadata-modules/view-field-group/dtos/view-field-group.dto';
import { type ViewFieldDTO } from 'src/engine/metadata-modules/view-field/dtos/view-field.dto';
import { type ViewFilterGroupDTO } from 'src/engine/metadata-modules/view-filter-group/dtos/view-filter-group.dto';
import { type ViewFilterDTO } from 'src/engine/metadata-modules/view-filter/dtos/view-filter.dto';
import { type ViewGroupDTO } from 'src/engine/metadata-modules/view-group/dtos/view-group.dto';
import { type ViewSortDTO } from 'src/engine/metadata-modules/view-sort/dtos/view-sort.dto';
import { type ViewDTO } from 'src/engine/metadata-modules/view/dtos/view.dto';
import { type ViewEntity } from 'src/engine/metadata-modules/view/entities/view.entity';
export const assertViewStructure = (
view: ViewDTO | ViewEntity,
expectedFields?: Partial<ViewDTO | ViewEntity>,
) => {
expect(view).toBeDefined();
expect(view.id).toBeDefined();
expect(view.name).toBeDefined();
expect(view.objectMetadataId).toBeDefined();
expect(view.workspaceId).toBeDefined();
expect(view.createdAt).toBeDefined();
expect(view.updatedAt).toBeDefined();
if (expectedFields) {
expect(view).toMatchObject(expectedFields);
}
};
export const assertViewFieldGroupStructure = (
viewFieldGroup: ViewFieldGroupDTO,
expectedFields?: Partial<ViewFieldGroupDTO>,
) => {
expect(viewFieldGroup).toBeDefined();
expect(viewFieldGroup.id).toBeDefined();
expect(viewFieldGroup.name).toBeDefined();
expect(viewFieldGroup.viewId).toBeDefined();
expect(typeof viewFieldGroup.position).toBe('number');
expect(typeof viewFieldGroup.isVisible).toBe('boolean');
if (expectedFields) {
expect(viewFieldGroup).toMatchObject(expectedFields);
}
};
export const assertViewFieldStructure = (
viewField: ViewFieldDTO,
expectedFields?: Partial<ViewFieldDTO>,
) => {
expect(viewField).toBeDefined();
expect(viewField.id).toBeDefined();
expect(viewField.fieldMetadataId).toBeDefined();
expect(viewField.viewId).toBeDefined();
expect(typeof viewField.position).toBe('number');
expect(typeof viewField.isVisible).toBe('boolean');
expect(typeof viewField.size).toBe('number');
if (expectedFields) {
expect(viewField).toMatchObject(expectedFields);
}
};
export const assertViewSortStructure = (
viewSort: ViewSortDTO,
expectedFields?: Partial<ViewSortDTO>,
) => {
expect(viewSort).toBeDefined();
expect(viewSort.id).toBeDefined();
expect(viewSort.fieldMetadataId).toBeDefined();
expect(viewSort.viewId).toBeDefined();
expect(viewSort.direction).toBeDefined();
expect(['ASC', 'DESC']).toContain(viewSort.direction);
if (expectedFields) {
expect(viewSort).toMatchObject(expectedFields);
}
};
export const assertViewFilterStructure = (
viewFilter: ViewFilterDTO,
expectedFields?: Partial<ViewFilterDTO>,
) => {
expect(viewFilter).toBeDefined();
expect(viewFilter.id).toBeDefined();
expect(viewFilter.fieldMetadataId).toBeDefined();
expect(viewFilter.viewId).toBeDefined();
expect(viewFilter.operand).toBeDefined();
expect(viewFilter.value).toBeDefined();
if (expectedFields) {
expect(viewFilter).toMatchObject(expectedFields);
}
};
export const assertViewGroupStructure = (
viewGroup: ViewGroupDTO,
expectedFields?: Partial<ViewGroupDTO>,
) => {
expect(viewGroup).toBeDefined();
expect(viewGroup.id).toBeDefined();
expect(viewGroup.viewId).toBeDefined();
expect(viewGroup.fieldValue).toBeDefined();
expect(typeof viewGroup.isVisible).toBe('boolean');
expect(typeof viewGroup.position).toBe('number');
if (expectedFields) {
expect(viewGroup).toMatchObject(expectedFields);
}
};
export const assertViewFilterGroupStructure = (
viewFilterGroup: ViewFilterGroupDTO,
expectedFields?: Partial<ViewFilterGroupDTO>,
validLogicalOperators: string[] = [
ViewFilterGroupLogicalOperator.AND,
ViewFilterGroupLogicalOperator.OR,
ViewFilterGroupLogicalOperator.NOT,
],
) => {
expect(viewFilterGroup).toBeDefined();
expect(viewFilterGroup.id).toBeDefined();
expect(viewFilterGroup.viewId).toBeDefined();
expect(viewFilterGroup.logicalOperator).toBeDefined();
expect(validLogicalOperators).toContain(viewFilterGroup.logicalOperator);
if (expectedFields) {
expect(viewFilterGroup).toMatchObject(expectedFields);
}
};

View File

@@ -0,0 +1,184 @@
import { Queue } from 'bullmq';
import IORedis from 'ioredis';
import { MessageQueue } from 'src/engine/core-modules/message-queue/message-queue.constants';
const POLL_INTERVAL_MS = 25;
const REQUIRED_CONSECUTIVE_QUIET_CHECKS = 2;
const STALL_TIMEOUT_MS = 15_000;
const DELAYED_JOB_PAGE_SIZE = 50;
const MAX_PROMOTABLE_DELAY_MS = 60_000;
const HARD_TIMEOUT_MS = 120_000;
const PENDING_JOB_STATES = [
'waiting',
'active',
'prioritized',
'waiting-children',
'delayed',
] as const;
type PendingJobCounts = {
pending: number;
delayed: number;
};
let redisConnection: IORedis | null = null;
let queues: Queue[] | null = null;
const getQueues = (): Queue[] => {
if (!queues) {
redisConnection = new IORedis(
process.env.REDIS_QUEUE_URL ??
process.env.REDIS_URL ??
'redis://localhost:6379',
{ maxRetriesPerRequest: null },
);
queues = Object.values(MessageQueue).map(
(queueName) => new Queue(queueName, { connection: redisConnection! }),
);
}
return queues;
};
const getPendingJobCountsByQueue = async (): Promise<
Record<string, PendingJobCounts>
> => {
const countsByQueue = await Promise.all(
getQueues().map(async (queue) => {
const jobCounts = await queue.getJobCounts(...PENDING_JOB_STATES);
const pendingCount = Object.values(jobCounts).reduce(
(sum, count) => sum + count,
0,
);
return [
queue.name,
{ pending: pendingCount, delayed: jobCounts.delayed ?? 0 },
] as const;
}),
);
return Object.fromEntries(
countsByQueue.filter(([, { pending }]) => pending > 0),
);
};
const promoteDelayedJobs = async (queueName: string): Promise<number> => {
const queue = getQueues().find(({ name }) => name === queueName);
if (!queue) {
return 0;
}
const delayedJobs = await queue.getDelayed(0, DELAYED_JOB_PAGE_SIZE);
const promotableJobs = delayedJobs.filter(
(job) => (job.delay ?? 0) <= MAX_PROMOTABLE_DELAY_MS,
);
let promotedJobCount = 0;
for (const promotableJob of promotableJobs) {
const hasBeenPromoted = await promotableJob
.promote()
.then(() => true)
.catch(() => false);
if (hasBeenPromoted) {
promotedJobCount += 1;
}
}
return promotedJobCount;
};
const getActiveJobsFingerprint = async (
busyQueueNames: string[],
): Promise<string> => {
const activeJobIdsByQueue = await Promise.all(
getQueues()
.filter((queue) => busyQueueNames.includes(queue.name))
.map(async (queue) => {
const activeJobs = await queue.getActive(0, 50);
return `${queue.name}:${activeJobs.map((job) => job.id).join(',')}`;
}),
);
return activeJobIdsByQueue.join('|');
};
export const waitForAllJobsToFinish = async (): Promise<void> => {
const startedAt = Date.now();
let lastProgressAt = startedAt;
let lastBusyFingerprint = '';
let consecutiveQuietChecks = 0;
while (consecutiveQuietChecks < REQUIRED_CONSECUTIVE_QUIET_CHECKS) {
const pendingJobCountsByQueue = await getPendingJobCountsByQueue();
const pendingTotal = Object.values(pendingJobCountsByQueue).reduce(
(sum, { pending }) => sum + pending,
0,
);
const stalledQueueNames = Object.entries(pendingJobCountsByQueue)
.filter(([, { pending, delayed }]) => pending === delayed)
.map(([queueName]) => queueName);
if (pendingTotal === 0) {
consecutiveQuietChecks += 1;
} else {
consecutiveQuietChecks = 0;
const now = Date.now();
if (now - startedAt > HARD_TIMEOUT_MS) {
throw new Error(
`Message queues still busy after ${HARD_TIMEOUT_MS}ms, pending jobs: ${JSON.stringify(pendingJobCountsByQueue)}`,
);
}
let promotedJobCount = 0;
for (const stalledQueueName of stalledQueueNames) {
promotedJobCount += await promoteDelayedJobs(stalledQueueName);
}
if (promotedJobCount > 0) {
lastProgressAt = now;
await new Promise((resolve) => setTimeout(resolve, POLL_INTERVAL_MS));
continue;
}
const activeJobsFingerprint = await getActiveJobsFingerprint(
Object.keys(pendingJobCountsByQueue),
);
const busyFingerprint = `${pendingTotal}|${activeJobsFingerprint}`;
if (busyFingerprint !== lastBusyFingerprint) {
lastBusyFingerprint = busyFingerprint;
lastProgressAt = now;
}
if (now - lastProgressAt > STALL_TIMEOUT_MS) {
throw new Error(
`Message queues stalled, no progress for ${STALL_TIMEOUT_MS}ms, pending jobs: ${JSON.stringify(pendingJobCountsByQueue)}`,
);
}
}
if (consecutiveQuietChecks < REQUIRED_CONSECUTIVE_QUIET_CHECKS) {
await new Promise((resolve) => setTimeout(resolve, POLL_INTERVAL_MS));
}
}
};
export const closeQueueConnections = async (): Promise<void> => {
if (queues) {
await Promise.allSettled(queues.map((queue) => queue.close()));
queues = null;
}
if (redisConnection) {
await redisConnection.quit().catch(() => redisConnection?.disconnect());
redisConnection = null;
}
};

View File

@@ -0,0 +1,19 @@
import { expectEventually } from 'test/integration/utils/expect-eventually.util';
const TEST_SCHEMA_NAME = 'workspace_1wgvd1injqtife6y4rvfbu3h5';
// Timeline activities are inserted by listeners after the mutation resolves;
// mutating a record while its activities are still in flight races those inserts.
export const waitForTimelineActivities = async (
targetColumnName: 'targetCompanyId' | 'targetPersonId',
recordIds: string[],
) => {
await expectEventually(async () => {
const [{ count }] = await global.testDataSource.query(
`SELECT count(DISTINCT "${targetColumnName}")::int AS count FROM "${TEST_SCHEMA_NAME}"."timelineActivity" WHERE "${targetColumnName}" = ANY($1)`,
[recordIds],
);
expect(count).toBe(recordIds.length);
});
};