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,29 @@
// Jest Snapshot v1, https://goo.gl/fbAQLP
exports[`Front component built JS endpoint should return 404 for a non-existent front component ID 1`] = `
{
"body": {
"code": "FRONT_COMPONENT_NOT_FOUND",
"error": "Error",
"messages": [
"Front component not found",
],
"statusCode": 404,
},
"status": 404,
}
`;
exports[`Front component built JS endpoint should return 404 when front component exists but built file is missing on storage 1`] = `
{
"body": {
"code": "FRONT_COMPONENT_NOT_FOUND",
"error": "Error",
"messages": [
"Front component built file not found",
],
"statusCode": 404,
},
"status": 404,
}
`;

View File

@@ -0,0 +1,34 @@
// Jest Snapshot v1, https://jestjs.io/docs/snapshot-testing
exports[`View Group REST API POST /metadata/viewGroups should fail to create view group with missing required fields 1`] = `
{
"error": "METADATA_VALIDATION_ERROR",
"errors": {
"viewGroup": [
{
"errors": [
{
"code": "INVALID_VIEW_DATA",
"message": "Field value is required",
"userFriendlyMessage": "Field value is required",
},
],
"flatEntityMinimalInformation": {
"universalIdentifier": Any<String>,
"viewUniversalIdentifier": Any<String>,
},
"metadataName": "viewGroup",
"status": "fail",
"type": "create",
},
],
},
"message": "Multiple validation errors occurred while creating view groups",
"statusCode": 400,
"summary": {
"totalErrors": 1,
"viewGroup": 1,
},
"userFriendlyMessage": "Field value is required",
}
`;

View File

@@ -0,0 +1,475 @@
import { updateFeatureFlag } from 'test/integration/metadata/suites/utils/update-feature-flag.util';
import { makeRestAPIRequest } from 'test/integration/rest/utils/make-rest-api-request.util';
import {
cleanupTestField,
cleanupTestObject,
createTestFieldViaGraphql,
createTestObjectViaGraphql,
extractMetadataItemPayload,
extractMetadataListPayload,
NON_EXISTENT_UUID,
uniqueSuffix,
} from 'test/integration/rest/utils/metadata-rest-api.util';
import {
assertRestApiErrorNotFoundResponse,
assertRestApiErrorResponse,
assertRestApiSuccessfulResponse,
} from 'test/integration/rest/utils/rest-test-assertions.util';
import { FeatureFlagKey, FieldMetadataType } from 'twenty-shared/types';
type FieldShape = {
id: string;
name?: string;
label?: string;
objectMetadataId?: string;
};
describe.each([
['new format', true],
['legacy format', false],
] as const)('Field Metadata REST API (%s)', (_shapeLabel, isNewFormat) => {
let parentObjectId: string;
beforeAll(async () => {
await updateFeatureFlag({
featureFlag: FeatureFlagKey.IS_REST_METADATA_API_NEW_FORMAT_DIRECT,
value: isNewFormat,
expectToFail: false,
});
const { id } = await createTestObjectViaGraphql();
parentObjectId = id;
});
afterAll(async () => {
await cleanupTestObject(parentObjectId);
await updateFeatureFlag({
featureFlag: FeatureFlagKey.IS_REST_METADATA_API_NEW_FORMAT_DIRECT,
value: false,
expectToFail: false,
});
});
describe('GET /metadata/fields', () => {
const seededIds: string[] = [];
beforeAll(async () => {
for (let i = 0; i < 3; i++) {
const { id } = await createTestFieldViaGraphql(parentObjectId);
seededIds.push(id);
}
});
afterAll(async () => {
// Field deletion runs schema migrations that acquire exclusive locks.
// Keep teardown serial so the suite cannot deadlock against itself.
for (const id of seededIds) {
await cleanupTestField(id);
}
seededIds.length = 0;
});
it('returns the expected envelope shape', async () => {
const response = await makeRestAPIRequest({
method: 'get',
path: '/metadata/fields',
bearer: APPLE_JANE_ADMIN_ACCESS_TOKEN,
});
assertRestApiSuccessfulResponse(response);
if (isNewFormat) {
expect(response.body).not.toHaveProperty('data.fields');
expect(Array.isArray(response.body.data)).toBe(true);
} else {
expect(Array.isArray(response.body.data.fields)).toBe(true);
}
expect(response.body).toHaveProperty('pageInfo.hasNextPage');
expect(response.body).toHaveProperty('pageInfo.hasPreviousPage');
expect(response.body).toHaveProperty('pageInfo.startCursor');
expect(response.body).toHaveProperty('pageInfo.endCursor');
expect(typeof response.body.totalCount).toBe('number');
expect(response.body.totalCount).toBeGreaterThanOrEqual(seededIds.length);
});
it('respects limit and surfaces hasNextPage', async () => {
const response = await makeRestAPIRequest({
method: 'get',
path: '/metadata/fields?limit=1',
bearer: APPLE_JANE_ADMIN_ACCESS_TOKEN,
});
assertRestApiSuccessfulResponse(response);
const { items, pageInfo } = extractMetadataListPayload<FieldShape>(
response.body,
'fields',
);
expect(items.length).toBe(1);
expect(pageInfo.hasNextPage).toBe(true);
expect(pageInfo.hasPreviousPage).toBe(false);
});
it('paginates forward with starting_after without overlap', async () => {
const firstPage = await makeRestAPIRequest({
method: 'get',
path: '/metadata/fields?limit=2',
bearer: APPLE_JANE_ADMIN_ACCESS_TOKEN,
});
const firstPayload = extractMetadataListPayload<FieldShape>(
firstPage.body,
'fields',
);
const secondPage = await makeRestAPIRequest({
method: 'get',
path: `/metadata/fields?limit=2&starting_after=${firstPayload.pageInfo.endCursor}`,
bearer: APPLE_JANE_ADMIN_ACCESS_TOKEN,
});
assertRestApiSuccessfulResponse(secondPage);
const secondPayload = extractMetadataListPayload<FieldShape>(
secondPage.body,
'fields',
);
const firstIds = firstPayload.items.map((f) => f.id);
const secondIds = secondPayload.items.map((f) => f.id);
expect(firstIds.some((id) => secondIds.includes(id))).toBe(false);
});
it('paginates backward with ending_before', async () => {
const firstPage = await makeRestAPIRequest({
method: 'get',
path: '/metadata/fields?limit=2',
bearer: APPLE_JANE_ADMIN_ACCESS_TOKEN,
});
const firstPayload = extractMetadataListPayload<FieldShape>(
firstPage.body,
'fields',
);
const secondPage = await makeRestAPIRequest({
method: 'get',
path: `/metadata/fields?limit=2&starting_after=${firstPayload.pageInfo.endCursor}`,
bearer: APPLE_JANE_ADMIN_ACCESS_TOKEN,
});
const secondPayload = extractMetadataListPayload<FieldShape>(
secondPage.body,
'fields',
);
const backPage = await makeRestAPIRequest({
method: 'get',
path: `/metadata/fields?limit=2&ending_before=${secondPayload.pageInfo.startCursor}`,
bearer: APPLE_JANE_ADMIN_ACCESS_TOKEN,
});
assertRestApiSuccessfulResponse(backPage);
const backPayload = extractMetadataListPayload<FieldShape>(
backPage.body,
'fields',
);
const firstIds = firstPayload.items.map((f) => f.id);
const backIds = backPayload.items.map((f) => f.id);
expect(backIds.every((id) => firstIds.includes(id))).toBe(true);
expect(backPayload.pageInfo.hasNextPage).toBe(true);
});
it('rejects combining starting_after and ending_before with 400', async () => {
const response = await makeRestAPIRequest({
method: 'get',
path: `/metadata/fields?starting_after=${NON_EXISTENT_UUID}&ending_before=${NON_EXISTENT_UUID}`,
bearer: APPLE_JANE_ADMIN_ACCESS_TOKEN,
});
assertRestApiErrorResponse(response, 400);
});
it('rejects malformed cursor IDs with 400', async () => {
const response = await makeRestAPIRequest({
method: 'get',
path: '/metadata/fields?starting_after=not-a-uuid',
bearer: APPLE_JANE_ADMIN_ACCESS_TOKEN,
});
assertRestApiErrorResponse(response, 400);
});
it('keeps totalCount stable across pages', async () => {
const firstPage = await makeRestAPIRequest({
method: 'get',
path: '/metadata/fields?limit=2',
bearer: APPLE_JANE_ADMIN_ACCESS_TOKEN,
});
const firstPayload = extractMetadataListPayload<FieldShape>(
firstPage.body,
'fields',
);
const secondPage = await makeRestAPIRequest({
method: 'get',
path: `/metadata/fields?limit=2&starting_after=${firstPayload.pageInfo.endCursor}`,
bearer: APPLE_JANE_ADMIN_ACCESS_TOKEN,
});
expect(firstPage.body.totalCount).toBe(secondPage.body.totalCount);
});
});
describe('GET /metadata/fields/:id', () => {
let testFieldId: string;
beforeAll(async () => {
const { id } = await createTestFieldViaGraphql(parentObjectId);
testFieldId = id;
});
afterAll(async () => {
await cleanupTestField(testFieldId);
});
it('returns the field with expected envelope', async () => {
const response = await makeRestAPIRequest({
method: 'get',
path: `/metadata/fields/${testFieldId}`,
bearer: APPLE_JANE_ADMIN_ACCESS_TOKEN,
});
assertRestApiSuccessfulResponse(response);
const field = extractMetadataItemPayload<FieldShape>(
response.body,
'field',
);
expect(field.id).toBe(testFieldId);
expect(field.objectMetadataId).toBe(parentObjectId);
if (isNewFormat) {
expect(response.body).not.toHaveProperty('data.field');
} else {
expect(response.body).toHaveProperty('data.field');
}
});
it('returns 400 on a malformed UUID', async () => {
const response = await makeRestAPIRequest({
method: 'get',
path: `/metadata/fields/not-a-uuid`,
bearer: APPLE_JANE_ADMIN_ACCESS_TOKEN,
});
assertRestApiErrorResponse(response, 400);
});
it('returns 404 for an unknown id', async () => {
const response = await makeRestAPIRequest({
method: 'get',
path: `/metadata/fields/${NON_EXISTENT_UUID}`,
bearer: APPLE_JANE_ADMIN_ACCESS_TOKEN,
});
assertRestApiErrorNotFoundResponse(response);
});
});
describe('POST /metadata/fields', () => {
it('creates a field and returns 201', async () => {
const suffix = uniqueSuffix();
const input = {
objectMetadataId: parentObjectId,
type: FieldMetadataType.TEXT,
name: `postField${suffix}`,
label: `Post Field ${suffix}`,
isLabelSyncedWithName: false,
};
const response = await makeRestAPIRequest({
method: 'post',
path: '/metadata/fields',
body: input,
bearer: APPLE_JANE_ADMIN_ACCESS_TOKEN,
});
const field = extractMetadataItemPayload<FieldShape>(
response.body,
'createOneField',
);
try {
assertRestApiSuccessfulResponse(response, 201);
expect(field.id).toBeDefined();
expect(field.name).toBe(input.name);
expect(field.objectMetadataId).toBe(parentObjectId);
if (isNewFormat) {
expect(response.body).not.toHaveProperty('data.createOneField');
} else {
expect(response.body).toHaveProperty('data.createOneField');
}
} finally {
if (field.id) {
await cleanupTestField(field.id);
}
}
});
it('returns 400 on duplicate name within an object', async () => {
const { id, input } = await createTestFieldViaGraphql(parentObjectId);
try {
const response = await makeRestAPIRequest({
method: 'post',
path: '/metadata/fields',
body: input,
bearer: APPLE_JANE_ADMIN_ACCESS_TOKEN,
});
assertRestApiErrorResponse(response, 400);
} finally {
await cleanupTestField(id);
}
});
it('returns 400 on invalid input', async () => {
const response = await makeRestAPIRequest({
method: 'post',
path: '/metadata/fields',
body: { name: '' },
bearer: APPLE_JANE_ADMIN_ACCESS_TOKEN,
});
assertRestApiErrorResponse(response, 400);
});
});
describe('PATCH /metadata/fields/:id', () => {
let testFieldId: string;
beforeEach(async () => {
const { id } = await createTestFieldViaGraphql(parentObjectId);
testFieldId = id;
});
afterEach(async () => {
await cleanupTestField(testFieldId);
});
it('updates and returns the field', async () => {
const newLabel = `Updated Field ${uniqueSuffix()}`;
const response = await makeRestAPIRequest({
method: 'patch',
path: `/metadata/fields/${testFieldId}`,
body: { label: newLabel },
bearer: APPLE_JANE_ADMIN_ACCESS_TOKEN,
});
assertRestApiSuccessfulResponse(response);
const field = extractMetadataItemPayload<FieldShape>(
response.body,
'updateOneField',
);
expect(field.id).toBe(testFieldId);
expect(field.label).toBe(newLabel);
if (isNewFormat) {
expect(response.body).not.toHaveProperty('data.updateOneField');
} else {
expect(response.body).toHaveProperty('data.updateOneField');
}
});
it('returns 400 for an unknown id', async () => {
const response = await makeRestAPIRequest({
method: 'patch',
path: `/metadata/fields/${NON_EXISTENT_UUID}`,
body: { label: 'Whatever' },
bearer: APPLE_JANE_ADMIN_ACCESS_TOKEN,
});
assertRestApiErrorResponse(response, 400);
});
});
describe('PUT /metadata/fields/:id', () => {
it('behaves equivalently to PATCH', async () => {
const { id } = await createTestFieldViaGraphql(parentObjectId);
try {
const newLabel = `PutField ${uniqueSuffix()}`;
const response = await makeRestAPIRequest({
method: 'put',
path: `/metadata/fields/${id}`,
body: { label: newLabel },
bearer: APPLE_JANE_ADMIN_ACCESS_TOKEN,
});
assertRestApiSuccessfulResponse(response);
const field = extractMetadataItemPayload<FieldShape>(
response.body,
'updateOneField',
);
expect(field.id).toBe(id);
expect(field.label).toBe(newLabel);
} finally {
await cleanupTestField(id);
}
});
});
describe('DELETE /metadata/fields/:id', () => {
it('deletes the field and returns the deleted resource', async () => {
const { id } = await createTestFieldViaGraphql(parentObjectId);
const response = await makeRestAPIRequest({
method: 'delete',
path: `/metadata/fields/${id}`,
bearer: APPLE_JANE_ADMIN_ACCESS_TOKEN,
});
assertRestApiSuccessfulResponse(response);
const deleted = extractMetadataItemPayload<FieldShape>(
response.body,
'deleteOneField',
);
expect(deleted.id).toBe(id);
if (isNewFormat) {
expect(response.body).not.toHaveProperty('data.deleteOneField');
} else {
expect(response.body).toHaveProperty('data.deleteOneField');
}
const getResponse = await makeRestAPIRequest({
method: 'get',
path: `/metadata/fields/${id}`,
bearer: APPLE_JANE_ADMIN_ACCESS_TOKEN,
});
assertRestApiErrorNotFoundResponse(getResponse);
});
it('returns 404 for an unknown id', async () => {
const response = await makeRestAPIRequest({
method: 'delete',
path: `/metadata/fields/${NON_EXISTENT_UUID}`,
bearer: APPLE_JANE_ADMIN_ACCESS_TOKEN,
});
assertRestApiErrorNotFoundResponse(response);
});
});
});

View File

@@ -0,0 +1,517 @@
import gql from 'graphql-tag';
import { TEST_COMPANY_1_ID } from 'test/integration/constants/test-company-ids.constants';
import { TEST_PERSON_1_ID } from 'test/integration/constants/test-person-ids.constants';
import { TEST_PRIMARY_LINK_URL } from 'test/integration/constants/test-primary-link-url.constant';
import { upsertFieldPermissions } from 'test/integration/graphql/utils/upsert-field-permissions.util';
import { upsertRowLevelPermissionPredicates } from 'test/integration/metadata/suites/row-level-permission-predicate/utils/upsert-row-level-permission-predicates.util';
import { makeMetadataAPIRequest } from 'test/integration/metadata/suites/utils/make-metadata-api-request.util';
import { makeRestAPIRequest } from 'test/integration/rest/utils/make-rest-api-request.util';
import { generateRecordName } from 'test/integration/utils/generate-record-name';
import { RowLevelPermissionPredicateOperand } from 'twenty-shared/types';
describe('Restricted fields', () => {
let personJobTitle: string;
let memberRoleId: string;
let personObjectId: string;
let emailsFieldId: string;
let phonesFieldId: string;
beforeAll(async () => {
personJobTitle = generateRecordName(TEST_PERSON_1_ID);
await makeRestAPIRequest({
method: 'post',
path: '/companies',
body: {
id: TEST_COMPANY_1_ID,
domainName: {
primaryLinkUrl: TEST_PRIMARY_LINK_URL,
},
},
});
await makeRestAPIRequest({
method: 'post',
path: '/people',
body: {
id: TEST_PERSON_1_ID,
jobTitle: personJobTitle,
emails: {
primaryEmail: 'test@test.com',
},
phones: {
primaryPhoneNumber: '123456789',
primaryPhoneCountryCode: 'US',
primaryPhoneCallingCode: '+1',
},
},
});
const getObjectMetadataOperation = {
query: gql`
query {
objects(paging: { first: 1000 }) {
edges {
node {
id
nameSingular
}
}
}
}
`,
};
const objectMetadataResponse = await makeMetadataAPIRequest(
getObjectMetadataOperation,
);
const objects = objectMetadataResponse.body.data.objects.edges;
personObjectId = objects.find(
(obj: any) => obj.node.nameSingular === 'person',
)?.node.id;
const getFieldMetadataOperation = {
query: gql`
query {
fields(paging: { first: 1000 }) {
edges {
node {
id
name
object {
nameSingular
}
}
}
}
}
`,
};
const fieldMetadataResponse = await makeMetadataAPIRequest(
getFieldMetadataOperation,
);
const fields = fieldMetadataResponse.body.data.fields.edges;
emailsFieldId = fields.find(
(field: any) =>
field.node.name === 'emails' &&
field.node.object.nameSingular === 'person',
).node.id;
phonesFieldId = fields.find(
(field: any) =>
field.node.name === 'phones' &&
field.node.object.nameSingular === 'person',
).node.id;
const getRolesOperation = {
query: gql`
query {
getRoles {
id
label
}
}
`,
};
const rolesResponse = await makeMetadataAPIRequest(getRolesOperation);
memberRoleId = rolesResponse.body.data.getRoles.find(
(role: any) => role.label === 'Member',
)?.id;
await upsertFieldPermissions({
roleId: memberRoleId,
fieldPermissions: [
{
objectMetadataId: personObjectId,
fieldMetadataId: emailsFieldId,
canReadFieldValue: false,
canUpdateFieldValue: null,
},
],
});
});
it('should hide fields when user has restricted read permissions - findOne', async () => {
await makeRestAPIRequest({
method: 'get',
path: `/people/${TEST_PERSON_1_ID}`,
bearer: APPLE_JONY_MEMBER_ACCESS_TOKEN,
})
.expect(200)
.expect((res) => {
const person = res.body.data.person;
expect(person).toBeDefined();
expect(person.id).toBeDefined();
expect(person.emails).toBeUndefined();
});
});
describe('updateOne', () => {
it('should hide fields in the response when user has restricted read permissions', async () => {
await upsertFieldPermissions({
roleId: memberRoleId,
fieldPermissions: [
{
objectMetadataId: personObjectId,
fieldMetadataId: phonesFieldId,
canReadFieldValue: false,
canUpdateFieldValue: null,
},
],
});
await makeRestAPIRequest({
method: 'patch',
path: `/people/${TEST_PERSON_1_ID}`,
bearer: APPLE_JONY_MEMBER_ACCESS_TOKEN,
body: {
name: {
firstName: 'John',
},
},
})
.expect(200)
.expect((res) => {
const updatedPerson = res.body.data.updatePerson;
expect(updatedPerson.name.firstName).toBe('John');
expect(updatedPerson.phones).toBeUndefined();
});
});
it('should block update when user tries to update non-updatable field', async () => {
await upsertFieldPermissions({
roleId: memberRoleId,
fieldPermissions: [
{
objectMetadataId: personObjectId,
fieldMetadataId: phonesFieldId,
canReadFieldValue: null,
canUpdateFieldValue: false,
},
],
});
await makeRestAPIRequest({
method: 'patch',
path: `/people/${TEST_PERSON_1_ID}`,
bearer: APPLE_JONY_MEMBER_ACCESS_TOKEN,
body: {
phones: {
primaryPhoneNumber: '987654321',
primaryPhoneCountryCode: 'FR',
primaryPhoneCallingCode: '+33',
},
},
})
.expect(400)
.expect((res) => {
expect(res.body.messages[0]).toContain(
'Entity performing the request does not have permission',
);
});
});
it('should allow update when user has no restricted update permissions', async () => {
await upsertFieldPermissions({
roleId: memberRoleId,
fieldPermissions: [
{
objectMetadataId: personObjectId,
fieldMetadataId: phonesFieldId,
canReadFieldValue: null,
canUpdateFieldValue: null,
},
],
});
await makeRestAPIRequest({
method: 'patch',
path: `/people/${TEST_PERSON_1_ID}`,
bearer: APPLE_JONY_MEMBER_ACCESS_TOKEN,
body: {
jobTitle: 'Updated City',
},
})
.expect(200)
.expect((res) => {
const updatedPerson = res.body.data.updatePerson;
expect(updatedPerson.jobTitle).toBe('Updated City');
});
});
});
describe('createOne', () => {
it('should block create when restricted field is not in any RLS predicate', async () => {
await upsertFieldPermissions({
roleId: memberRoleId,
fieldPermissions: [
{
objectMetadataId: personObjectId,
fieldMetadataId: phonesFieldId,
canReadFieldValue: null,
canUpdateFieldValue: false,
},
],
});
await makeRestAPIRequest({
method: 'post',
path: `/people`,
bearer: APPLE_JONY_MEMBER_ACCESS_TOKEN,
body: {
phones: {
primaryPhoneNumber: '555123456',
primaryPhoneCountryCode: 'US',
primaryPhoneCallingCode: '+1',
},
},
})
.expect(400)
.expect((res) => {
expect(res.body.messages[0]).toContain(
'Entity performing the request does not have permission',
);
});
});
it('should allow create when restricted field is referenced in an RLS predicate', async () => {
await upsertFieldPermissions({
roleId: memberRoleId,
fieldPermissions: [
{
objectMetadataId: personObjectId,
fieldMetadataId: phonesFieldId,
canReadFieldValue: null,
canUpdateFieldValue: false,
},
],
});
await upsertRowLevelPermissionPredicates({
input: {
roleId: memberRoleId,
objectMetadataId: personObjectId,
predicates: [
{
fieldMetadataId: phonesFieldId,
operand: RowLevelPermissionPredicateOperand.IS_NOT_EMPTY,
},
],
predicateGroups: [],
},
});
await makeRestAPIRequest({
method: 'post',
path: `/people`,
bearer: APPLE_JONY_MEMBER_ACCESS_TOKEN,
body: {
phones: {
primaryPhoneNumber: '555123456',
primaryPhoneCountryCode: 'US',
primaryPhoneCallingCode: '+1',
},
},
})
.expect(201)
.expect((res) => {
const createdPerson = res.body.data.createPerson;
expect(createdPerson).toBeDefined();
expect(createdPerson.phones.primaryPhoneNumber).toBe('555123456');
});
await upsertRowLevelPermissionPredicates({
input: {
roleId: memberRoleId,
objectMetadataId: personObjectId,
predicates: [],
predicateGroups: [],
},
});
});
it('should allow create when user has no restricted update permissions', async () => {
await upsertFieldPermissions({
roleId: memberRoleId,
fieldPermissions: [
{
objectMetadataId: personObjectId,
fieldMetadataId: phonesFieldId,
canReadFieldValue: null,
canUpdateFieldValue: null,
},
{
objectMetadataId: personObjectId,
fieldMetadataId: emailsFieldId,
canReadFieldValue: false,
canUpdateFieldValue: null,
},
],
});
await makeRestAPIRequest({
method: 'post',
path: `/people`,
bearer: APPLE_JONY_MEMBER_ACCESS_TOKEN,
body: {
jobTitle: 'New City',
},
})
.expect(201)
.expect((res) => {
const createdPerson = res.body.data.createPerson;
expect(createdPerson.jobTitle).toBe('New City');
expect(createdPerson.emails).toBeUndefined();
});
});
});
describe('createMany', () => {
it('should block createMany when restricted field is not in any RLS predicate', async () => {
await upsertFieldPermissions({
roleId: memberRoleId,
fieldPermissions: [
{
objectMetadataId: personObjectId,
fieldMetadataId: phonesFieldId,
canReadFieldValue: null,
canUpdateFieldValue: false,
},
],
});
await makeRestAPIRequest({
method: 'post',
path: `/batch/people`,
bearer: APPLE_JONY_MEMBER_ACCESS_TOKEN,
body: [
{
phones: {
primaryPhoneNumber: '555123456',
primaryPhoneCountryCode: 'US',
primaryPhoneCallingCode: '+1',
},
},
],
})
.expect(400)
.expect((res) => {
expect(res.body.messages[0]).toContain(
'Entity performing the request does not have permission',
);
});
});
it('should allow createMany when restricted field is referenced in an RLS predicate', async () => {
await upsertFieldPermissions({
roleId: memberRoleId,
fieldPermissions: [
{
objectMetadataId: personObjectId,
fieldMetadataId: phonesFieldId,
canReadFieldValue: null,
canUpdateFieldValue: false,
},
],
});
await upsertRowLevelPermissionPredicates({
input: {
roleId: memberRoleId,
objectMetadataId: personObjectId,
predicates: [
{
fieldMetadataId: phonesFieldId,
operand: RowLevelPermissionPredicateOperand.IS_NOT_EMPTY,
},
],
predicateGroups: [],
},
});
await makeRestAPIRequest({
method: 'post',
path: `/batch/people`,
bearer: APPLE_JONY_MEMBER_ACCESS_TOKEN,
body: [
{
phones: {
primaryPhoneNumber: '555123456',
primaryPhoneCountryCode: 'US',
primaryPhoneCallingCode: '+1',
},
},
],
})
.expect(201)
.expect((res) => {
const createdPeople = res.body.data.createPeople;
expect(createdPeople).toHaveLength(1);
expect(createdPeople[0].phones.primaryPhoneNumber).toBe('555123456');
});
await upsertRowLevelPermissionPredicates({
input: {
roleId: memberRoleId,
objectMetadataId: personObjectId,
predicates: [],
predicateGroups: [],
},
});
});
it('should allow createMany when user has no restricted update permissions', async () => {
await upsertFieldPermissions({
roleId: memberRoleId,
fieldPermissions: [
{
objectMetadataId: personObjectId,
fieldMetadataId: phonesFieldId,
canReadFieldValue: null,
canUpdateFieldValue: null,
},
{
objectMetadataId: personObjectId,
fieldMetadataId: emailsFieldId,
canReadFieldValue: false,
canUpdateFieldValue: null,
},
],
});
await makeRestAPIRequest({
method: 'post',
path: `/batch/people`,
bearer: APPLE_JONY_MEMBER_ACCESS_TOKEN,
body: [
{
jobTitle: 'Batch City 1',
},
{
jobTitle: 'Batch City 2',
},
],
})
.expect(201)
.expect((res) => {
const createdPeople = res.body.data.createPeople;
expect(createdPeople).toHaveLength(2);
expect(createdPeople[0].jobTitle).toBe('Batch City 1');
expect(createdPeople[0].emails).toBeUndefined();
expect(createdPeople[1].jobTitle).toBe('Batch City 2');
expect(createdPeople[1].emails).toBeUndefined();
});
});
});
});

View File

@@ -0,0 +1,137 @@
import { createFrontComponent } from 'test/integration/metadata/suites/front-component/utils/create-front-component.util';
import { deleteFrontComponent } from 'test/integration/metadata/suites/front-component/utils/delete-front-component.util';
import { seedBuiltFrontComponentFile } from 'test/integration/metadata/suites/front-component/utils/seed-built-front-component-file.util';
import { makeRestAPIRequest } from 'test/integration/rest/utils/make-rest-api-request.util';
import { expectOneNotInternalServerErrorHttpResponseSnapshot } from 'test/integration/utils/expect-one-not-internal-server-error-http-response-snapshot.util';
const BUILT_COMPONENT_PATH = 'src/front-components/test-endpoint.mjs';
describe('Front component built JS endpoint', () => {
let frontComponentId: string;
let cleanupBuiltFile: (() => void) | undefined;
beforeAll(async () => {
const { cleanup } = await seedBuiltFrontComponentFile({
builtComponentPath: BUILT_COMPONENT_PATH,
});
cleanupBuiltFile = cleanup;
const { data } = await createFrontComponent({
expectToFail: false,
input: {
name: 'testBuiltJsEndpoint',
componentName: 'TestBuiltJsEndpoint',
sourceComponentPath: 'src/front-components/test-endpoint.tsx',
builtComponentPath: BUILT_COMPONENT_PATH,
builtComponentChecksum: 'test-checksum-123',
},
});
frontComponentId = data.createFrontComponent.id;
});
afterAll(async () => {
if (frontComponentId) {
await deleteFrontComponent({
expectToFail: false,
input: { id: frontComponentId },
});
}
cleanupBuiltFile?.();
});
it('should serve the built JS file with correct content type', async () => {
await makeRestAPIRequest({
method: 'get',
path: `/front-components/${frontComponentId}`,
bearer: APPLE_JANE_ADMIN_ACCESS_TOKEN,
})
.expect(200)
.expect('Content-Type', /application\/javascript/)
.expect((res) => {
expect(res.text).toBe('dummy built component content');
});
});
it('should serve the built JS from the checksum-fingerprinted path with an immutable cache header', async () => {
await makeRestAPIRequest({
method: 'get',
path: `/front-components/${frontComponentId}/test-checksum-123.js`,
bearer: APPLE_JANE_ADMIN_ACCESS_TOKEN,
})
.expect(200)
.expect('Content-Type', /application\/javascript/)
.expect('Cache-Control', 'private, max-age=86400, immutable')
.expect((res) => {
expect(res.text).toBe('dummy built component content');
});
});
it('should return 404 for a non-existent front component ID', async () => {
const nonExistentId = '00000000-0000-0000-0000-000000000000';
const response = await makeRestAPIRequest({
method: 'get',
path: `/front-components/${nonExistentId}`,
bearer: APPLE_JANE_ADMIN_ACCESS_TOKEN,
});
expectOneNotInternalServerErrorHttpResponseSnapshot(response);
});
it('should return 404 when front component exists but built file is missing on storage', async () => {
const missingBuiltPath = 'src/front-components/will-be-deleted.mjs';
const { cleanup } = await seedBuiltFrontComponentFile({
builtComponentPath: missingBuiltPath,
});
const { data } = await createFrontComponent({
expectToFail: false,
input: {
name: 'testMissingBuiltFile',
componentName: 'TestMissingBuiltFile',
sourceComponentPath: 'src/front-components/will-be-deleted.tsx',
builtComponentPath: missingBuiltPath,
builtComponentChecksum: 'will-be-deleted-checksum',
},
});
const missingFileComponentId = data.createFrontComponent.id;
cleanup();
try {
const response = await makeRestAPIRequest({
method: 'get',
path: `/front-components/${missingFileComponentId}`,
bearer: APPLE_JANE_ADMIN_ACCESS_TOKEN,
});
expectOneNotInternalServerErrorHttpResponseSnapshot(response);
} finally {
await deleteFrontComponent({
expectToFail: false,
input: { id: missingFileComponentId },
});
}
});
it('should return 403 when no token is provided', async () => {
await makeRestAPIRequest({
method: 'get',
path: `/front-components/${frontComponentId}`,
bearer: '',
}).expect(403);
});
it('should return 401 when an invalid token is provided', async () => {
await makeRestAPIRequest({
method: 'get',
path: `/front-components/${frontComponentId}`,
bearer: INVALID_ACCESS_TOKEN,
}).expect(401);
});
});

View File

@@ -0,0 +1,99 @@
import { seedBuiltFrontComponentSharedDependenciesFile } from 'test/integration/metadata/suites/application/utils/seed-built-front-component-shared-dependencies-file.util';
import { makeRestAPIRequest } from 'test/integration/rest/utils/make-rest-api-request.util';
import { SEED_APPLE_WORKSPACE_ID } from 'src/engine/workspace-manager/dev-seeder/core/constants/seeder-workspaces.constant';
const SHARED_DEPENDENCIES_BUILT_PATH =
'src/front-component-shared-dependencies.mjs';
const SHARED_DEPENDENCIES_CHECKSUM = 'a'.repeat(64);
const SHARED_DEPENDENCIES_BUNDLE_CONTENT =
'export const sharedDependenciesReady = true;\n';
describe('Front component shared dependencies endpoint', () => {
let applicationId: string;
let applicationUniversalIdentifier: string;
let cleanupSharedDependenciesFile: (() => void) | undefined;
beforeAll(async () => {
const [workspace] = await global.testDataSource.query(
'SELECT "workspaceCustomApplicationId" FROM core.workspace WHERE id = $1',
[SEED_APPLE_WORKSPACE_ID],
);
applicationUniversalIdentifier = workspace.workspaceCustomApplicationId;
const [application] = await global.testDataSource.query(
'SELECT id FROM core."application" WHERE "universalIdentifier" = $1 AND "workspaceId" = $2',
[applicationUniversalIdentifier, SEED_APPLE_WORKSPACE_ID],
);
applicationId = application.id;
});
afterAll(async () => {
await global.testDataSource.query(
'UPDATE core."application" SET "frontComponentSharedDependenciesBuiltPath" = NULL, "frontComponentSharedDependenciesChecksum" = NULL WHERE id = $1',
[applicationId],
);
cleanupSharedDependenciesFile?.();
});
it('should return 404 when the application declares no shared dependencies bundle', async () => {
await makeRestAPIRequest({
method: 'get',
path: `/front-component-shared-dependencies/${applicationId}`,
bearer: APPLE_JANE_ADMIN_ACCESS_TOKEN,
}).expect(404);
});
describe('with a built shared dependencies bundle', () => {
beforeAll(async () => {
const { cleanup } = seedBuiltFrontComponentSharedDependenciesFile({
applicationUniversalIdentifier,
builtPath: SHARED_DEPENDENCIES_BUILT_PATH,
content: SHARED_DEPENDENCIES_BUNDLE_CONTENT,
});
cleanupSharedDependenciesFile = cleanup;
await global.testDataSource.query(
'UPDATE core."application" SET "frontComponentSharedDependenciesBuiltPath" = $1, "frontComponentSharedDependenciesChecksum" = $2 WHERE id = $3',
[
SHARED_DEPENDENCIES_BUILT_PATH,
SHARED_DEPENDENCIES_CHECKSUM,
applicationId,
],
);
});
it('should serve the shared dependencies bundle with no-store on the plain path', async () => {
await makeRestAPIRequest({
method: 'get',
path: `/front-component-shared-dependencies/${applicationId}`,
bearer: APPLE_JANE_ADMIN_ACCESS_TOKEN,
})
.expect(200)
.expect('Content-Type', /application\/javascript/)
.expect('X-Content-Type-Options', 'nosniff')
.expect('Content-Disposition', 'attachment')
.expect('Cache-Control', 'private, no-store')
.expect((res) => {
expect(res.text).toBe(SHARED_DEPENDENCIES_BUNDLE_CONTENT);
});
});
it('should serve the checksum-fingerprinted path with an immutable cache header', async () => {
await makeRestAPIRequest({
method: 'get',
path: `/front-component-shared-dependencies/${applicationId}/${SHARED_DEPENDENCIES_CHECKSUM}.js`,
bearer: APPLE_JANE_ADMIN_ACCESS_TOKEN,
})
.expect(200)
.expect('Cache-Control', 'private, max-age=86400, immutable')
.expect((res) => {
expect(res.text).toBe(SHARED_DEPENDENCIES_BUNDLE_CONTENT);
});
});
});
});

View File

@@ -0,0 +1,63 @@
import { updateFeatureFlag } from 'test/integration/metadata/suites/utils/update-feature-flag.util';
import { makeRestAPIRequest } from 'test/integration/rest/utils/make-rest-api-request.util';
import { assertMetadataRestListResponse } from 'test/integration/rest/utils/rest-test-assertions.util';
import { FeatureFlagKey } from 'twenty-shared/types';
const EMPTY_PARENT_ID = '00000000-0000-4000-8000-000000000000';
describe('Metadata REST list contract', () => {
// objects and fields still serve the legacy envelope when the flag is off,
// so pin it on for the unified-envelope contract.
beforeAll(async () => {
await updateFeatureFlag({
featureFlag: FeatureFlagKey.IS_REST_METADATA_API_NEW_FORMAT_DIRECT,
value: true,
expectToFail: false,
});
});
afterAll(async () => {
await updateFeatureFlag({
featureFlag: FeatureFlagKey.IS_REST_METADATA_API_NEW_FORMAT_DIRECT,
value: false,
expectToFail: false,
});
});
it.each([
'/metadata/objects',
'/metadata/fields',
'/metadata/webhooks',
'/metadata/apiKeys',
'/metadata/views',
'/metadata/viewFields',
'/metadata/viewFilters',
'/metadata/viewSorts',
'/metadata/viewGroups',
'/metadata/viewFilterGroups',
'/metadata/pageLayouts',
`/metadata/pageLayoutTabs?pageLayoutId=${EMPTY_PARENT_ID}`,
`/metadata/pageLayoutWidgets?pageLayoutTabId=${EMPTY_PARENT_ID}`,
])('returns the unified page envelope from GET %s', async (path) => {
const response = await makeRestAPIRequest({
method: 'get',
path,
bearer: APPLE_JANE_ADMIN_ACCESS_TOKEN,
});
assertMetadataRestListResponse(response);
});
it('keeps deprecated non-metadata aliases as raw arrays', async () => {
for (const path of ['/webhooks', '/apiKeys']) {
const response = await makeRestAPIRequest({
method: 'get',
path,
bearer: APPLE_JANE_ADMIN_ACCESS_TOKEN,
});
expect(response.status).toBe(200);
expect(Array.isArray(response.body)).toBe(true);
}
});
});

View File

@@ -0,0 +1,517 @@
import { updateFeatureFlag } from 'test/integration/metadata/suites/utils/update-feature-flag.util';
import { makeRestAPIRequest } from 'test/integration/rest/utils/make-rest-api-request.util';
import {
cleanupTestObject,
createTestObjectViaGraphql,
extractMetadataItemPayload,
extractMetadataListPayload,
NON_EXISTENT_UUID,
uniqueSuffix,
} from 'test/integration/rest/utils/metadata-rest-api.util';
import {
assertRestApiErrorNotFoundResponse,
assertRestApiErrorResponse,
assertRestApiSuccessfulResponse,
} from 'test/integration/rest/utils/rest-test-assertions.util';
import { FeatureFlagKey } from 'twenty-shared/types';
type ObjectShape = { id: string; fields: unknown[]; labelSingular?: string };
describe.each([
['new format', true],
['legacy format', false],
] as const)('Object Metadata REST API (%s)', (_shapeLabel, isNewFormat) => {
beforeAll(async () => {
await updateFeatureFlag({
featureFlag: FeatureFlagKey.IS_REST_METADATA_API_NEW_FORMAT_DIRECT,
value: isNewFormat,
expectToFail: false,
});
});
afterAll(async () => {
await updateFeatureFlag({
featureFlag: FeatureFlagKey.IS_REST_METADATA_API_NEW_FORMAT_DIRECT,
value: false,
expectToFail: false,
});
});
describe('GET /metadata/objects', () => {
const seededIds: string[] = [];
beforeAll(async () => {
for (let i = 0; i < 3; i++) {
const { id } = await createTestObjectViaGraphql();
seededIds.push(id);
}
});
afterAll(async () => {
// Object deletion runs schema migrations that acquire exclusive locks.
// Keep teardown serial so the suite cannot deadlock against itself.
for (const id of seededIds) {
await cleanupTestObject(id);
}
seededIds.length = 0;
});
it('returns the expected envelope shape', async () => {
const response = await makeRestAPIRequest({
method: 'get',
path: '/metadata/objects',
bearer: APPLE_JANE_ADMIN_ACCESS_TOKEN,
});
assertRestApiSuccessfulResponse(response);
if (isNewFormat) {
expect(response.body).not.toHaveProperty('data.objects');
expect(Array.isArray(response.body.data)).toBe(true);
} else {
expect(Array.isArray(response.body.data.objects)).toBe(true);
}
expect(response.body).toHaveProperty('pageInfo.hasNextPage');
expect(response.body).toHaveProperty('pageInfo.hasPreviousPage');
expect(response.body).toHaveProperty('pageInfo.startCursor');
expect(response.body).toHaveProperty('pageInfo.endCursor');
expect(typeof response.body.totalCount).toBe('number');
expect(response.body.totalCount).toBeGreaterThanOrEqual(seededIds.length);
});
it('inlines fields[] on each object', async () => {
const response = await makeRestAPIRequest({
method: 'get',
path: '/metadata/objects?limit=5',
bearer: APPLE_JANE_ADMIN_ACCESS_TOKEN,
});
assertRestApiSuccessfulResponse(response);
const { items } = extractMetadataListPayload<ObjectShape>(
response.body,
'objects',
);
for (const object of items) {
expect(Array.isArray(object.fields)).toBe(true);
}
});
it('respects limit and surfaces hasNextPage', async () => {
const response = await makeRestAPIRequest({
method: 'get',
path: '/metadata/objects?limit=1',
bearer: APPLE_JANE_ADMIN_ACCESS_TOKEN,
});
assertRestApiSuccessfulResponse(response);
const { items, pageInfo } = extractMetadataListPayload<ObjectShape>(
response.body,
'objects',
);
expect(items.length).toBe(1);
expect(pageInfo.hasNextPage).toBe(true);
expect(pageInfo.hasPreviousPage).toBe(false);
expect(pageInfo.startCursor).toBe(items[0].id);
expect(pageInfo.endCursor).toBe(items[0].id);
});
it('paginates forward with starting_after without overlap', async () => {
const firstPage = await makeRestAPIRequest({
method: 'get',
path: '/metadata/objects?limit=2',
bearer: APPLE_JANE_ADMIN_ACCESS_TOKEN,
});
assertRestApiSuccessfulResponse(firstPage);
const firstPayload = extractMetadataListPayload<ObjectShape>(
firstPage.body,
'objects',
);
const secondPage = await makeRestAPIRequest({
method: 'get',
path: `/metadata/objects?limit=2&starting_after=${firstPayload.pageInfo.endCursor}`,
bearer: APPLE_JANE_ADMIN_ACCESS_TOKEN,
});
assertRestApiSuccessfulResponse(secondPage);
const secondPayload = extractMetadataListPayload<ObjectShape>(
secondPage.body,
'objects',
);
const firstIds = firstPayload.items.map((o) => o.id);
const secondIds = secondPayload.items.map((o) => o.id);
expect(firstIds.some((id) => secondIds.includes(id))).toBe(false);
});
it('paginates backward with ending_before', async () => {
const firstPage = await makeRestAPIRequest({
method: 'get',
path: '/metadata/objects?limit=2',
bearer: APPLE_JANE_ADMIN_ACCESS_TOKEN,
});
const firstPayload = extractMetadataListPayload<ObjectShape>(
firstPage.body,
'objects',
);
const secondPage = await makeRestAPIRequest({
method: 'get',
path: `/metadata/objects?limit=2&starting_after=${firstPayload.pageInfo.endCursor}`,
bearer: APPLE_JANE_ADMIN_ACCESS_TOKEN,
});
const secondPayload = extractMetadataListPayload<ObjectShape>(
secondPage.body,
'objects',
);
const backPage = await makeRestAPIRequest({
method: 'get',
path: `/metadata/objects?limit=2&ending_before=${secondPayload.pageInfo.startCursor}`,
bearer: APPLE_JANE_ADMIN_ACCESS_TOKEN,
});
assertRestApiSuccessfulResponse(backPage);
const backPayload = extractMetadataListPayload<ObjectShape>(
backPage.body,
'objects',
);
const firstIds = firstPayload.items.map((o) => o.id);
const backIds = backPayload.items.map((o) => o.id);
expect(backIds.every((id) => firstIds.includes(id))).toBe(true);
expect(backPayload.pageInfo.hasNextPage).toBe(true);
});
it('rejects combining starting_after and ending_before with 400', async () => {
const response = await makeRestAPIRequest({
method: 'get',
path: `/metadata/objects?starting_after=${NON_EXISTENT_UUID}&ending_before=${NON_EXISTENT_UUID}`,
bearer: APPLE_JANE_ADMIN_ACCESS_TOKEN,
});
assertRestApiErrorResponse(response, 400);
});
it('rejects malformed cursor IDs with 400', async () => {
const response = await makeRestAPIRequest({
method: 'get',
path: '/metadata/objects?starting_after=not-a-uuid',
bearer: APPLE_JANE_ADMIN_ACCESS_TOKEN,
});
assertRestApiErrorResponse(response, 400);
});
it('keeps totalCount stable across pages', async () => {
const firstPage = await makeRestAPIRequest({
method: 'get',
path: '/metadata/objects?limit=2',
bearer: APPLE_JANE_ADMIN_ACCESS_TOKEN,
});
const firstPayload = extractMetadataListPayload<ObjectShape>(
firstPage.body,
'objects',
);
const secondPage = await makeRestAPIRequest({
method: 'get',
path: `/metadata/objects?limit=2&starting_after=${firstPayload.pageInfo.endCursor}`,
bearer: APPLE_JANE_ADMIN_ACCESS_TOKEN,
});
assertRestApiSuccessfulResponse(firstPage);
assertRestApiSuccessfulResponse(secondPage);
expect(firstPage.body.totalCount).toBe(secondPage.body.totalCount);
});
it('reports hasNextPage=false when the page covers all results', async () => {
const response = await makeRestAPIRequest({
method: 'get',
path: '/metadata/objects?limit=200',
bearer: APPLE_JANE_ADMIN_ACCESS_TOKEN,
});
assertRestApiSuccessfulResponse(response);
const { items, pageInfo } = extractMetadataListPayload<ObjectShape>(
response.body,
'objects',
);
expect(items.length).toBe(response.body.totalCount);
expect(pageInfo.hasNextPage).toBe(false);
});
});
describe('GET /metadata/objects/:id', () => {
let testObjectId: string;
beforeAll(async () => {
const { id } = await createTestObjectViaGraphql();
testObjectId = id;
});
afterAll(async () => {
await cleanupTestObject(testObjectId);
});
it('returns the object with fields[] populated', async () => {
const response = await makeRestAPIRequest({
method: 'get',
path: `/metadata/objects/${testObjectId}`,
bearer: APPLE_JANE_ADMIN_ACCESS_TOKEN,
});
assertRestApiSuccessfulResponse(response);
const object = extractMetadataItemPayload<ObjectShape>(
response.body,
'object',
);
expect(object.id).toBe(testObjectId);
expect(Array.isArray(object.fields)).toBe(true);
expect(object.fields.length).toBeGreaterThan(0);
if (isNewFormat) {
expect(response.body).not.toHaveProperty('data.object');
} else {
expect(response.body).toHaveProperty('data.object');
}
});
it('returns 400 on a malformed UUID', async () => {
const response = await makeRestAPIRequest({
method: 'get',
path: `/metadata/objects/not-a-uuid`,
bearer: APPLE_JANE_ADMIN_ACCESS_TOKEN,
});
assertRestApiErrorResponse(response, 400);
});
it('returns 404 for an unknown id', async () => {
const response = await makeRestAPIRequest({
method: 'get',
path: `/metadata/objects/${NON_EXISTENT_UUID}`,
bearer: APPLE_JANE_ADMIN_ACCESS_TOKEN,
});
assertRestApiErrorNotFoundResponse(response);
});
});
describe('POST /metadata/objects', () => {
it('creates an object and returns 201 with default fields[]', async () => {
const suffix = uniqueSuffix();
const input = {
nameSingular: `postObj${suffix}`,
namePlural: `postObj${suffix}s`,
labelSingular: `Post Obj ${suffix}`,
labelPlural: `Post Objs ${suffix}`,
icon: 'IconTestPipe',
isLabelSyncedWithName: false,
};
const response = await makeRestAPIRequest({
method: 'post',
path: '/metadata/objects',
body: input,
bearer: APPLE_JANE_ADMIN_ACCESS_TOKEN,
});
const object = extractMetadataItemPayload<ObjectShape>(
response.body,
'createOneObject',
);
try {
assertRestApiSuccessfulResponse(response, 201);
expect(object.id).toBeDefined();
expect(
(object as ObjectShape & { nameSingular: string }).nameSingular,
).toBe(input.nameSingular);
expect(Array.isArray(object.fields)).toBe(true);
expect(object.fields.length).toBeGreaterThan(0);
if (isNewFormat) {
expect(response.body).not.toHaveProperty('data.createOneObject');
} else {
expect(response.body).toHaveProperty('data.createOneObject');
}
} finally {
if (object.id) {
await cleanupTestObject(object.id);
}
}
});
it('returns 400 on duplicate nameSingular', async () => {
const { id, input } = await createTestObjectViaGraphql();
try {
const response = await makeRestAPIRequest({
method: 'post',
path: '/metadata/objects',
body: input,
bearer: APPLE_JANE_ADMIN_ACCESS_TOKEN,
});
assertRestApiErrorResponse(response, 400);
} finally {
await cleanupTestObject(id);
}
});
it('returns 400 on invalid input', async () => {
const response = await makeRestAPIRequest({
method: 'post',
path: '/metadata/objects',
body: { nameSingular: '' },
bearer: APPLE_JANE_ADMIN_ACCESS_TOKEN,
});
assertRestApiErrorResponse(response, 400);
});
});
describe('PATCH /metadata/objects/:id', () => {
let testObjectId: string;
beforeEach(async () => {
const { id } = await createTestObjectViaGraphql();
testObjectId = id;
});
afterEach(async () => {
await cleanupTestObject(testObjectId);
});
it('updates and returns the object with fields[]', async () => {
const newLabel = `Updated ${uniqueSuffix()}`;
const response = await makeRestAPIRequest({
method: 'patch',
path: `/metadata/objects/${testObjectId}`,
body: { labelSingular: newLabel },
bearer: APPLE_JANE_ADMIN_ACCESS_TOKEN,
});
assertRestApiSuccessfulResponse(response);
const object = extractMetadataItemPayload<ObjectShape>(
response.body,
'updateOneObject',
);
expect(object.id).toBe(testObjectId);
expect(object.labelSingular).toBe(newLabel);
expect(Array.isArray(object.fields)).toBe(true);
if (isNewFormat) {
expect(response.body).not.toHaveProperty('data.updateOneObject');
} else {
expect(response.body).toHaveProperty('data.updateOneObject');
}
});
it('returns 404 for an unknown id', async () => {
const response = await makeRestAPIRequest({
method: 'patch',
path: `/metadata/objects/${NON_EXISTENT_UUID}`,
body: { labelSingular: 'Whatever' },
bearer: APPLE_JANE_ADMIN_ACCESS_TOKEN,
});
assertRestApiErrorNotFoundResponse(response);
});
});
describe('PUT /metadata/objects/:id', () => {
it('behaves equivalently to PATCH', async () => {
const { id } = await createTestObjectViaGraphql();
try {
const newLabel = `PutUpdate ${uniqueSuffix()}`;
const response = await makeRestAPIRequest({
method: 'put',
path: `/metadata/objects/${id}`,
body: { labelSingular: newLabel },
bearer: APPLE_JANE_ADMIN_ACCESS_TOKEN,
});
assertRestApiSuccessfulResponse(response);
const object = extractMetadataItemPayload<ObjectShape>(
response.body,
'updateOneObject',
);
expect(object.id).toBe(id);
expect(object.labelSingular).toBe(newLabel);
expect(Array.isArray(object.fields)).toBe(true);
} finally {
await cleanupTestObject(id);
}
});
});
describe('DELETE /metadata/objects/:id', () => {
it('deletes the object and returns the deleted resource', async () => {
const { id } = await createTestObjectViaGraphql();
const patchResponse = await makeRestAPIRequest({
method: 'patch',
path: `/metadata/objects/${id}`,
body: { isActive: false },
bearer: APPLE_JANE_ADMIN_ACCESS_TOKEN,
});
assertRestApiSuccessfulResponse(patchResponse);
const deleteResponse = await makeRestAPIRequest({
method: 'delete',
path: `/metadata/objects/${id}`,
bearer: APPLE_JANE_ADMIN_ACCESS_TOKEN,
});
assertRestApiSuccessfulResponse(deleteResponse);
const deleted = extractMetadataItemPayload<{ id: string }>(
deleteResponse.body,
'deleteOneObject',
);
expect(deleted.id).toBe(id);
if (isNewFormat) {
expect(deleteResponse.body).not.toHaveProperty('data.deleteOneObject');
} else {
expect(deleteResponse.body).toHaveProperty('data.deleteOneObject');
}
const getResponse = await makeRestAPIRequest({
method: 'get',
path: `/metadata/objects/${id}`,
bearer: APPLE_JANE_ADMIN_ACCESS_TOKEN,
});
assertRestApiErrorNotFoundResponse(getResponse);
});
it('returns 404 for an unknown id', async () => {
const response = await makeRestAPIRequest({
method: 'delete',
path: `/metadata/objects/${NON_EXISTENT_UUID}`,
bearer: APPLE_JANE_ADMIN_ACCESS_TOKEN,
});
assertRestApiErrorNotFoundResponse(response);
});
});
});

View File

@@ -0,0 +1,55 @@
import { makeRestAPIRequest } from 'test/integration/rest/utils/make-rest-api-request.util';
describe('Core REST API Authentication', () => {
it('should return an UnauthorizedException when no token is provided', async () => {
await makeRestAPIRequest({
method: 'post',
path: `/people`,
bearer: '',
})
.expect(403)
.expect((res) => {
expect(res.body.error).toBe('FORBIDDEN_EXCEPTION');
expect(res.body.messages[0]).toBe('Missing authentication token');
});
});
it('should return an Unauthenticated when an invalid token is provided', async () => {
await makeRestAPIRequest({
method: 'post',
path: `/people`,
bearer: INVALID_ACCESS_TOKEN,
})
.expect(401)
.expect((res) => {
expect(res.body.error).toBe('UNAUTHENTICATED');
expect(res.body.messages[0]).toBe('Token invalid.');
});
});
it('should return an Unauthenticated when a malformed token is provided', async () => {
await makeRestAPIRequest({
method: 'post',
path: `/people`,
bearer: 'invalid-token',
})
.expect(401)
.expect((res) => {
expect(res.body.error).toBe('UNAUTHENTICATED');
expect(res.body.messages[0]).toBe('Token invalid.');
});
});
it('should return an Unauthenticated when an expired token is provided', async () => {
await makeRestAPIRequest({
method: 'post',
path: `/people`,
bearer: EXPIRED_ACCESS_TOKEN,
})
.expect(401)
.expect((res) => {
expect(res.body.error).toBe('UNAUTHENTICATED');
expect(res.body.messages[0]).toBe('Token has expired.');
});
});
});

View File

@@ -0,0 +1,229 @@
import { TEST_COMPANY_1_ID } from 'test/integration/constants/test-company-ids.constants';
import {
TEST_PERSON_1_ID,
TEST_PERSON_2_ID,
} from 'test/integration/constants/test-person-ids.constants';
import { TEST_PRIMARY_LINK_URL } from 'test/integration/constants/test-primary-link-url.constant';
import { makeRestAPIRequest } from 'test/integration/rest/utils/make-rest-api-request.util';
import { deleteAllRecords } from 'test/integration/utils/delete-all-records';
import { FieldActorSource } from 'twenty-shared/types';
import { WORKSPACE_MEMBER_DATA_SEED_IDS } from 'src/engine/workspace-manager/dev-seeder/data/constants/workspace-member-data-seeds.constant';
describe('Core REST API Create Many endpoint', () => {
beforeEach(async () => {
await deleteAllRecords('person');
await makeRestAPIRequest({
method: 'post',
path: '/companies',
body: {
id: TEST_COMPANY_1_ID,
domainName: {
primaryLinkUrl: TEST_PRIMARY_LINK_URL,
},
},
});
});
it('should create many person', async () => {
const requestBody = [
{
id: TEST_PERSON_1_ID,
},
{
id: TEST_PERSON_2_ID,
},
];
await makeRestAPIRequest({
method: 'post',
path: `/batch/people`,
body: requestBody,
})
.expect(201)
.expect((res) => {
const createdPeople = res.body.data.createPeople;
expect(createdPeople.length).toBe(2);
expect(createdPeople[0].id).toBe(TEST_PERSON_1_ID);
expect(createdPeople[0].createdBy.source).toBe(FieldActorSource.API);
expect(createdPeople[0].createdBy.workspaceMemberId).toBe(null);
expect(createdPeople[1].id).toBe(TEST_PERSON_2_ID);
expect(createdPeople[1].createdBy.source).toBe(FieldActorSource.API);
expect(createdPeople[1].createdBy.workspaceMemberId).toBe(null);
});
});
it('should create a new person with specific createdBy', async () => {
const requestBody = [
{
id: TEST_PERSON_1_ID,
createdBy: {
source: FieldActorSource.EMAIL,
},
},
{
id: TEST_PERSON_2_ID,
createdBy: {
source: FieldActorSource.MANUAL,
},
},
];
await makeRestAPIRequest({
method: 'post',
path: `/batch/people`,
body: requestBody,
})
.expect(201)
.expect((res) => {
const createdPeople = res.body.data.createPeople;
expect(createdPeople[0].createdBy.source).toBe(FieldActorSource.EMAIL);
expect(createdPeople[0].createdBy.workspaceMemberId).toBe(null);
expect(createdPeople[1].createdBy.source).toBe(FieldActorSource.MANUAL);
expect(createdPeople[1].createdBy.workspaceMemberId).toBe(null);
});
});
it('should create many person with MANUAL createdBy if user identified', async () => {
const requestBody = [
{
id: TEST_PERSON_1_ID,
},
{
id: TEST_PERSON_2_ID,
},
];
await makeRestAPIRequest({
method: 'post',
path: `/batch/people`,
body: requestBody,
bearer: APPLE_JANE_ADMIN_ACCESS_TOKEN,
})
.expect(201)
.expect((res) => {
const createdPeople = res.body.data.createPeople;
expect(createdPeople.length).toBe(2);
expect(createdPeople[0].createdBy.source).toBe(FieldActorSource.MANUAL);
expect(createdPeople[0].createdBy.workspaceMemberId).toBe(
WORKSPACE_MEMBER_DATA_SEED_IDS.JANE,
);
expect(createdPeople[1].createdBy.source).toBe(FieldActorSource.MANUAL);
expect(createdPeople[1].createdBy.workspaceMemberId).toBe(
WORKSPACE_MEMBER_DATA_SEED_IDS.JANE,
);
});
});
it('should support depth 0 parameter', async () => {
const requestBody = [
{
id: TEST_PERSON_1_ID,
companyId: TEST_COMPANY_1_ID,
},
{
id: TEST_PERSON_2_ID,
companyId: TEST_COMPANY_1_ID,
},
];
await makeRestAPIRequest({
method: 'post',
path: `/batch/people?depth=0`,
body: requestBody,
})
.expect(201)
.expect((res) => {
const [createdPerson1, createdPerson2] = res.body.data.createPeople;
expect(createdPerson1.companyId).toBeDefined();
expect(createdPerson1.company).not.toBeDefined();
expect(createdPerson2.companyId).toBeDefined();
expect(createdPerson2.company).not.toBeDefined();
});
});
it('should support depth 1 parameter', async () => {
const requestBody = [
{
id: TEST_PERSON_1_ID,
companyId: TEST_COMPANY_1_ID,
},
{
id: TEST_PERSON_2_ID,
companyId: TEST_COMPANY_1_ID,
},
];
await makeRestAPIRequest({
method: 'post',
path: `/batch/people?depth=1`,
body: requestBody,
})
.expect(201)
.expect((res) => {
const [createdPerson1, createdPerson2] = res.body.data.createPeople;
expect(createdPerson1.company).toBeDefined();
expect(createdPerson1.company.people).not.toBeDefined();
expect(createdPerson2.company).toBeDefined();
expect(createdPerson2.company.people).not.toBeDefined();
});
});
it('should not support depth 2 parameter', async () => {
const requestBody = [
{
id: TEST_PERSON_1_ID,
companyId: TEST_COMPANY_1_ID,
},
{
id: TEST_PERSON_2_ID,
companyId: TEST_COMPANY_1_ID,
},
];
await makeRestAPIRequest({
method: 'post',
path: `/batch/people?depth=2`,
body: requestBody,
}).expect(400);
});
it('should return a BadRequestException when trying to create a person with an existing ID', async () => {
const requestBody = [
{
id: TEST_PERSON_1_ID,
},
{
id: TEST_PERSON_2_ID,
},
];
await makeRestAPIRequest({
method: 'post',
path: `/batch/people`,
body: requestBody,
});
await makeRestAPIRequest({
method: 'post',
path: `/batch/people`,
body: requestBody,
})
.expect(400)
.expect((res) => {
expect(res.body.messages[0]).toContain(
`A duplicate entry was detected`,
);
expect(res.body.error).toBe('BadRequestException');
});
});
});

View File

@@ -0,0 +1,216 @@
import { TEST_COMPANY_1_ID } from 'test/integration/constants/test-company-ids.constants';
import { TEST_PERSON_1_ID } from 'test/integration/constants/test-person-ids.constants';
import {
TEST_PRIMARY_LINK_URL,
TEST_PRIMARY_LINK_URL_AS_DOMAIN,
} from 'test/integration/constants/test-primary-link-url.constant';
import { makeRestAPIRequest } from 'test/integration/rest/utils/make-rest-api-request.util';
import { deleteAllRecords } from 'test/integration/utils/delete-all-records';
import { generateRecordName } from 'test/integration/utils/generate-record-name';
import { FieldActorSource } from 'twenty-shared/types';
import { WORKSPACE_MEMBER_DATA_SEED_IDS } from 'src/engine/workspace-manager/dev-seeder/data/constants/workspace-member-data-seeds.constant';
describe('Core REST API Create One endpoint', () => {
beforeEach(async () => {
await deleteAllRecords('person');
await deleteAllRecords('company');
await makeRestAPIRequest({
method: 'post',
path: '/companies',
body: {
id: TEST_COMPANY_1_ID,
domainName: {
primaryLinkUrl: TEST_PRIMARY_LINK_URL,
},
},
});
});
afterAll(async () => {
await deleteAllRecords('person');
await deleteAllRecords('company');
});
it('should create a new person', async () => {
const personJobTitle = generateRecordName(TEST_PERSON_1_ID);
const requestBody = {
id: TEST_PERSON_1_ID,
jobTitle: personJobTitle,
companyId: TEST_COMPANY_1_ID,
};
await makeRestAPIRequest({
method: 'post',
path: `/people`,
body: requestBody,
})
.expect(201)
.expect((res) => {
const createdPerson = res.body.data.createPerson;
expect(createdPerson.id).toBe(TEST_PERSON_1_ID);
expect(createdPerson.jobTitle).toBe(personJobTitle);
expect(createdPerson.createdBy.source).toBe(FieldActorSource.API);
expect(createdPerson.createdBy.workspaceMemberId).toBe(null);
});
});
it('should create a new person with specific createdBy', async () => {
const personJobTitle = generateRecordName(TEST_PERSON_1_ID);
const requestBody = {
id: TEST_PERSON_1_ID,
jobTitle: personJobTitle,
companyId: TEST_COMPANY_1_ID,
createdBy: {
source: FieldActorSource.EMAIL,
},
};
await makeRestAPIRequest({
method: 'post',
path: `/people`,
body: requestBody,
})
.expect(201)
.expect((res) => {
const createdPerson = res.body.data.createPerson;
expect(createdPerson.createdBy.source).toBe(FieldActorSource.EMAIL);
expect(createdPerson.createdBy.workspaceMemberId).toBe(null);
});
});
it('should create a new person with MANUAL createdBy if user identified', async () => {
const personJobTitle = generateRecordName(TEST_PERSON_1_ID);
const requestBody = {
id: TEST_PERSON_1_ID,
jobTitle: personJobTitle,
companyId: TEST_COMPANY_1_ID,
};
await makeRestAPIRequest({
method: 'post',
path: `/people`,
body: requestBody,
bearer: APPLE_JANE_ADMIN_ACCESS_TOKEN,
})
.expect(201)
.expect((res) => {
const createdPerson = res.body.data.createPerson;
expect(createdPerson.createdBy.source).toBe(FieldActorSource.MANUAL);
expect(createdPerson.createdBy.workspaceMemberId).toBe(
WORKSPACE_MEMBER_DATA_SEED_IDS.JANE,
);
});
});
it('should support depth 0 parameter', async () => {
const personJobTitle = generateRecordName(TEST_PERSON_1_ID);
const requestBody = {
id: TEST_PERSON_1_ID,
jobTitle: personJobTitle,
companyId: TEST_COMPANY_1_ID,
};
await makeRestAPIRequest({
method: 'post',
path: `/people?depth=0`,
body: requestBody,
})
.expect(201)
.expect((res) => {
const createdPerson = res.body.data.createPerson;
expect(createdPerson.companyId).toBeDefined();
expect(createdPerson.company).not.toBeDefined();
});
});
it('should support depth 1 parameter', async () => {
const personJobTitle = generateRecordName(TEST_PERSON_1_ID);
const requestBody = {
id: TEST_PERSON_1_ID,
jobTitle: personJobTitle,
companyId: TEST_COMPANY_1_ID,
};
await makeRestAPIRequest({
method: 'post',
path: `/people?depth=1`,
body: requestBody,
})
.expect(201)
.expect((res) => {
const createdPerson = res.body.data.createPerson;
expect(createdPerson.company).toBeDefined();
expect(createdPerson.company.domainName.primaryLinkUrl).toBe(
TEST_PRIMARY_LINK_URL_AS_DOMAIN,
);
expect(createdPerson.company.people).not.toBeDefined();
});
});
it('should not support depth 2 parameter', async () => {
const personJobTitle = generateRecordName(TEST_PERSON_1_ID);
const requestBody = {
id: TEST_PERSON_1_ID,
jobTitle: personJobTitle,
companyId: TEST_COMPANY_1_ID,
};
await makeRestAPIRequest({
method: 'post',
path: `/people?depth=2`,
body: requestBody,
}).expect(400);
});
it('should return a BadRequestException when trying to create a person with an existing ID', async () => {
const personJobTitle = generateRecordName(TEST_PERSON_1_ID);
const requestBody = {
id: TEST_PERSON_1_ID,
jobTitle: personJobTitle,
};
await makeRestAPIRequest({
method: 'post',
path: `/people`,
body: requestBody,
});
await makeRestAPIRequest({
method: 'post',
path: `/people`,
body: requestBody,
})
.expect(400)
.expect((res) => {
expect(res.body.messages[0]).toContain(
`A duplicate entry was detected`,
);
expect(res.body.error).toBe('BadRequestException');
});
});
it('should return a BadRequestException when trying to create an opportunity with an invalid enum', async () => {
const requestBody = {
stage: 'INVALID_ENUM_VALUE',
};
await makeRestAPIRequest({
method: 'post',
path: `/opportunities`,
body: requestBody,
})
.expect(400)
.expect((res) => {
expect(res.body.messages[0]).toMatch(
'Invalid value "INVALID_ENUM_VALUE" for field "stage". Valid values are: NEW, SCREENING, MEETING, PROPOSAL, CUSTOMER',
);
expect(res.body.error).toBe('BadRequestException');
});
});
});

View File

@@ -0,0 +1,82 @@
import {
NOT_EXISTING_TEST_PERSON_ID,
TEST_PERSON_1_ID,
} from 'test/integration/constants/test-person-ids.constants';
import { makeRestAPIRequest } from 'test/integration/rest/utils/make-rest-api-request.util';
import { deleteAllRecords } from 'test/integration/utils/delete-all-records';
describe('Core REST API Delete One endpoint', () => {
beforeAll(async () => {
await deleteAllRecords('person');
});
beforeEach(async () => {
await makeRestAPIRequest({
method: 'post',
path: `/people`,
body: {
id: TEST_PERSON_1_ID,
},
});
});
it('should delete one person', async () => {
await makeRestAPIRequest({
method: 'delete',
path: `/people/${TEST_PERSON_1_ID}`,
})
.expect(200)
.expect((res) =>
expect(res.body.data.deletePerson).toEqual({ id: TEST_PERSON_1_ID }),
);
});
it('should return a EntityNotFoundError when trying to delete a non-existing person', async () => {
const response = await makeRestAPIRequest({
method: 'delete',
path: `/people/${NOT_EXISTING_TEST_PERSON_ID}`,
});
expect(response.status).toBe(404);
expect(response.body.error).toBe('NotFoundException');
expect(response.body.messages[0]).toBe('Record not found');
});
});
describe('Core REST API Delete Many endpoint', () => {
beforeAll(async () => {
await deleteAllRecords('person');
});
it('should require filters for bulk delete operations', async () => {
const response = await makeRestAPIRequest({
method: 'delete',
path: `/people?soft_delete=true`,
});
expect(response.status).toBe(400);
expect(response.body.error).toBe('BadRequestException');
expect(response.body.messages[0]).toContain(
'Filters are mandatory for bulk delete operations',
);
});
});
describe('Core REST API Destroy Many endpoint', () => {
beforeAll(async () => {
await deleteAllRecords('person');
});
it('should require filters for bulk destroy operations', async () => {
const response = await makeRestAPIRequest({
method: 'delete',
path: `/people?soft_delete=false`,
});
expect(response.status).toBe(400);
expect(response.body.error).toBe('BadRequestException');
expect(response.body.messages[0]).toContain(
'Filters are mandatory for bulk destroy operations',
);
});
});

View File

@@ -0,0 +1,259 @@
import { TEST_COMPANY_1_ID } from 'test/integration/constants/test-company-ids.constants';
import {
TEST_PERSON_1_ID,
TEST_PERSON_2_ID,
TEST_PERSON_3_ID,
} from 'test/integration/constants/test-person-ids.constants';
import { TEST_PRIMARY_LINK_URL } from 'test/integration/constants/test-primary-link-url.constant';
import { makeRestAPIRequest } from 'test/integration/rest/utils/make-rest-api-request.util';
import { deleteAllRecords } from 'test/integration/utils/delete-all-records';
describe('Core REST API Find Duplicates endpoint', () => {
beforeAll(async () => {
await deleteAllRecords('person');
await deleteAllRecords('company');
await makeRestAPIRequest({
method: 'post',
path: '/companies',
body: {
id: TEST_COMPANY_1_ID,
domainName: {
primaryLinkUrl: TEST_PRIMARY_LINK_URL,
},
},
}).expect(201);
await makeRestAPIRequest({
method: 'post',
path: '/batch/people',
body: [
{
id: TEST_PERSON_1_ID,
companyId: TEST_COMPANY_1_ID,
name: {
firstName: 'John',
lastName: 'Doe',
},
},
{
id: TEST_PERSON_2_ID,
companyId: TEST_COMPANY_1_ID,
name: {
firstName: 'John',
lastName: 'Doe',
},
},
{
id: TEST_PERSON_3_ID,
companyId: TEST_COMPANY_1_ID,
name: {
firstName: 'Phil',
lastName: 'Collins',
},
},
],
}).expect(201);
});
it('should retrieve duplicates by object data', async () => {
const response = await makeRestAPIRequest({
method: 'post',
path: `/people/duplicates`,
body: {
data: [
{
name: {
firstName: 'John',
lastName: 'Doe',
},
},
],
},
}).expect(200);
const data = response.body.data;
expect(data.length).toBe(1);
const duplicatesInfo = data[0];
expect(duplicatesInfo.totalCount).toBe(2);
expect(duplicatesInfo.personDuplicates.length).toBe(2);
const [personDuplicated1, personDuplicated2] =
duplicatesInfo.personDuplicates;
expect(personDuplicated1.id).toBe(TEST_PERSON_1_ID);
expect(personDuplicated2.id).toBe(TEST_PERSON_2_ID);
});
it('should retrieve duplicates by ids', async () => {
const response = await makeRestAPIRequest({
method: 'post',
path: `/people/duplicates`,
body: {
ids: [TEST_PERSON_1_ID],
},
}).expect(200);
const data = response.body.data;
expect(data.length).toBe(1);
const duplicatesInfo = data[0];
expect(duplicatesInfo.totalCount).toBe(1);
expect(duplicatesInfo.personDuplicates.length).toBe(1);
const [personDuplicated] = duplicatesInfo.personDuplicates;
expect(personDuplicated.id).toBe(TEST_PERSON_2_ID);
});
it('should not provide wrong duplicates', async () => {
const response = await makeRestAPIRequest({
method: 'post',
path: `/people/duplicates`,
body: {
data: [
{
name: {
firstName: 'Not',
lastName: 'Existing',
},
},
],
},
}).expect(200);
const data = response.body.data;
expect(data.length).toBe(1);
const duplicatesInfo = data[0];
expect(duplicatesInfo.totalCount).toBe(0);
expect(duplicatesInfo.personDuplicates.length).toBe(0);
});
it('should return 400 error when empty object data provided', async () => {
const response = await makeRestAPIRequest({
method: 'post',
path: `/people/duplicates`,
body: {
data: [],
},
}).expect(400);
expect(response.body.messages[0]).toContain(
'The "data" condition can not be empty when "ids" input not provided',
);
expect(response.body.error).toBe('BadRequestException');
});
it('should return empty result when empty ids provided', async () => {
const response = await makeRestAPIRequest({
method: 'post',
path: `/people/duplicates`,
body: {
ids: [],
},
}).expect(200);
expect(response.body.data.length).toBe(0);
});
it('should return 400 error when ids and data are provided', async () => {
const response = await makeRestAPIRequest({
method: 'post',
path: `/people/duplicates`,
body: {
data: [],
ids: [],
},
}).expect(400);
expect(response.body.messages[0]).toContain(
'You cannot provide both "data" and "ids" arguments',
);
expect(response.body.error).toBe('BadRequestException');
});
it('should support depth 0 parameter', async () => {
const response = await makeRestAPIRequest({
method: 'post',
path: `/people/duplicates?depth=0`,
body: {
data: [
{
name: {
firstName: 'John',
lastName: 'Doe',
},
},
],
},
}).expect(200);
const data = response.body.data;
expect(data.length).toBe(1);
const duplicatesInfo = data[0];
const [personDuplicated1, personDuplicated2] =
duplicatesInfo.personDuplicates;
expect(personDuplicated1.companyId).toBe(TEST_COMPANY_1_ID);
expect(personDuplicated1.company).not.toBeDefined();
expect(personDuplicated2.companyId).toBe(TEST_COMPANY_1_ID);
expect(personDuplicated2.company).not.toBeDefined();
});
it('should support depth 1 parameter', async () => {
const response = await makeRestAPIRequest({
method: 'post',
path: `/people/duplicates?depth=1`,
body: {
data: [
{
name: {
firstName: 'John',
lastName: 'Doe',
},
},
],
},
}).expect(200);
const data = response.body.data;
expect(data.length).toBe(1);
const duplicatesInfo = data[0];
const [personDuplicated1, personDuplicated2] =
duplicatesInfo.personDuplicates;
expect(personDuplicated1.company).toBeDefined();
expect(personDuplicated1.company.id).toBe(TEST_COMPANY_1_ID);
expect(personDuplicated1.company.people).not.toBeDefined();
expect(personDuplicated2.company).toBeDefined();
expect(personDuplicated2.company.id).toBe(TEST_COMPANY_1_ID);
expect(personDuplicated2.company.people).not.toBeDefined();
});
it('should not support depth 2 parameter', async () => {
await makeRestAPIRequest({
method: 'post',
path: `/people/duplicates?depth=2`,
body: {
data: [
{
name: {
firstName: 'John',
lastName: 'Doe',
},
},
],
},
}).expect(400);
});
});

View File

@@ -0,0 +1,506 @@
import { TEST_COMPANY_1_ID } from 'test/integration/constants/test-company-ids.constants';
import {
TEST_PERSON_1_ID,
TEST_PERSON_2_ID,
TEST_PERSON_3_ID,
TEST_PERSON_4_ID,
} from 'test/integration/constants/test-person-ids.constants';
import {
TEST_PRIMARY_LINK_URL,
TEST_PRIMARY_LINK_URL_AS_DOMAIN,
} from 'test/integration/constants/test-primary-link-url.constant';
import { makeRestAPIRequest } from 'test/integration/rest/utils/make-rest-api-request.util';
import { deleteAllRecords } from 'test/integration/utils/delete-all-records';
import { generateRecordName } from 'test/integration/utils/generate-record-name';
describe('Core REST API Find Many endpoint', () => {
const testPersonIds = [
TEST_PERSON_1_ID,
TEST_PERSON_2_ID,
TEST_PERSON_3_ID,
TEST_PERSON_4_ID,
];
const testPersonJobTitles: Record<string, string> = {};
beforeAll(async () => {
await deleteAllRecords('person');
await makeRestAPIRequest({
method: 'post',
path: '/companies',
body: {
id: TEST_COMPANY_1_ID,
domainName: {
primaryLinkUrl: TEST_PRIMARY_LINK_URL,
},
},
});
let index = 0;
for (const personId of testPersonIds) {
const jobTitle = generateRecordName(personId);
testPersonJobTitles[personId] = jobTitle;
await makeRestAPIRequest({
method: 'post',
path: '/people',
body: {
id: personId,
jobTitle: jobTitle,
position: index,
companyId: TEST_COMPANY_1_ID,
},
});
index++;
}
});
it('should retrieve all people with pagination metadata', async () => {
const response = await makeRestAPIRequest({
method: 'get',
path: '/people',
});
expect(response.status).toBe(200);
const people = response.body.data.people;
const pageInfo = response.body.pageInfo;
const totalCount = response.body.totalCount;
expect(people).not.toBeNull();
expect(Array.isArray(people)).toBe(true);
expect(people.length).toBeGreaterThanOrEqual(testPersonIds.length);
for (const personId of testPersonIds) {
// @ts-expect-error legacy noImplicitAny
const person = people.find((p) => p.id === personId);
expect(person).toBeDefined();
expect(person.jobTitle).toBe(testPersonJobTitles[personId]);
}
expect(pageInfo).toBeDefined();
expect(pageInfo.startCursor).toBeDefined();
expect(pageInfo.endCursor).toBeDefined();
expect(typeof totalCount).toBe('number');
expect(totalCount).toEqual(testPersonIds.length);
expect(response.body.pageInfo.hasNextPage).toBe(false);
expect(response.body.pageInfo.hasPreviousPage).toBe(false);
});
it('should limit results based on the limit parameter', async () => {
const limit = testPersonIds.length - 1;
const response = await makeRestAPIRequest({
method: 'get',
path: `/people?limit=${limit}`,
}).expect(200);
const people = response.body.data.people;
expect(people).not.toBeNull();
expect(Array.isArray(people)).toBe(true);
expect(people.length).toEqual(limit);
expect(response.body.totalCount).toEqual(testPersonIds.length);
expect(response.body.pageInfo.hasNextPage).toBe(true);
});
it('should return filtered totalCount', async () => {
const response = await makeRestAPIRequest({
method: 'get',
path: `/people?filter=position[lte]:1`,
}).expect(200);
const people = response.body.data.people;
expect(people).not.toBeNull();
expect(Array.isArray(people)).toBe(true);
expect(people.length).toEqual(2);
expect(response.body.totalCount).toEqual(2);
expect(response.body.pageInfo.hasNextPage).toBe(false);
});
it('should filter results based on filter parameters', async () => {
const response = await makeRestAPIRequest({
method: 'get',
path: '/people?filter=position[lt]:2',
}).expect(200);
const filteredPeople = response.body.data.people;
expect(filteredPeople).toBeDefined();
expect(Array.isArray(filteredPeople)).toBe(true);
expect(filteredPeople.length).toBe(2);
});
it('should support cursor-based pagination with starting_after', async () => {
const initialResponse = await makeRestAPIRequest({
method: 'get',
path: '/people?limit=2',
}).expect(200);
const people = initialResponse.body.data.people;
const startCursor = initialResponse.body.pageInfo.startCursor;
expect(people).toBeDefined();
expect(people.length).toBe(2);
expect(startCursor).toBeDefined();
const nextPageResponse = await makeRestAPIRequest({
method: 'get',
path: `/people?starting_after=${startCursor}&limit=1`,
}).expect(200);
const nextPagePeople = nextPageResponse.body.data.people;
expect(nextPagePeople).toBeDefined();
expect(nextPagePeople.length).toBe(1);
expect(nextPagePeople[0].id).toBe(people[1].id);
expect(nextPageResponse.body.pageInfo.hasPreviousPage).toBe(true);
});
it('should support cursor-based pagination with ending_before', async () => {
const initialResponse = await makeRestAPIRequest({
method: 'get',
path: '/people?limit=4',
}).expect(200);
const people = initialResponse.body.data.people;
const endCursor = initialResponse.body.pageInfo.endCursor;
expect(people).toBeDefined();
expect(people.length).toBe(4);
expect(endCursor).toBeDefined();
const nextPageResponse = await makeRestAPIRequest({
method: 'get',
path: `/people?ending_before=${endCursor}&limit=2`,
}).expect(200);
const nextPagePeople = nextPageResponse.body.data.people;
expect(nextPagePeople).toBeDefined();
expect(nextPagePeople.length).toBe(2);
expect(nextPagePeople[0].id).toBe(people[1].id);
expect(nextPagePeople[1].id).toBe(people[2].id);
expect(nextPageResponse.body.pageInfo.hasNextPage).toBe(true);
});
it('should support ordering Asc of results', async () => {
const ascResponse = await makeRestAPIRequest({
method: 'get',
path: '/people?order_by=position[AscNullsLast]',
}).expect(200);
const ascPeople = ascResponse.body.data.people;
expect(ascPeople).toEqual(
[...ascPeople].sort((a, b) => a.position - b.position),
);
});
it('should support filtering on a relation field id', async () => {
const response = await makeRestAPIRequest({
method: 'get',
path: `/people?filter=companyId[in]:["${TEST_COMPANY_1_ID}"]`,
}).expect(200);
const filteredPeople = response.body.data.people;
expect(filteredPeople.length).toBeGreaterThan(0);
});
// TODO: Refacto-common - Uncomment this after https://github.com/twentyhq/core-team-issues/issues/1627
// expect(response.body).toMatchInlineSnapshot(`
// {
// "error": "BadRequestException",
// "messages": [
// "field 'company' does not exist in 'person' object",
// ],
// "statusCode": 400,
// }
// `);
// });
it('should support ordering Desc of results', async () => {
const descResponse = await makeRestAPIRequest({
method: 'get',
path: '/people?order_by=position[DescNullsLast]',
}).expect(200);
const descPeople = descResponse.body.data.people;
expect(descPeople).toEqual(
[...descPeople].sort((a, b) => -(a.position - b.position)),
);
});
it('should support pagination with ordering', async () => {
const descResponse = await makeRestAPIRequest({
method: 'get',
path: '/people?order_by=position[DescNullsLast]&limit=2',
}).expect(200);
const descPeople = descResponse.body.data.people;
const endingBefore = descResponse.body.pageInfo.endCursor;
const lastPosition = descPeople[descPeople.length - 1].position;
expect(descResponse.body.pageInfo.hasNextPage).toBe(true);
expect(descPeople.length).toEqual(2);
expect(lastPosition).toEqual(2);
const descResponseWithPaginationResponse = await makeRestAPIRequest({
method: 'get',
path: `/people?order_by=position[DescNullsLast]&limit=2&starting_after=${endingBefore}`,
}).expect(200);
const descResponseWithPagination =
descResponseWithPaginationResponse.body.data.people;
expect(descResponseWithPagination.length).toEqual(2);
expect(descResponseWithPagination[0].position).toEqual(lastPosition - 1);
});
it('should handle invalid cursor gracefully', async () => {
const response = await makeRestAPIRequest({
method: 'get',
path: '/people?starting_after=invalid-cursor',
});
expect(response.body.error).toBe('BadRequestException');
expect(response.body.messages[0]).toContain('Invalid cursor');
});
it('should combine filtering, ordering, and pagination', async () => {
const response = await makeRestAPIRequest({
method: 'get',
path: '/people?filter=position[gt]:0&order_by=jobTitle[AscNullsFirst]&limit=2',
}).expect(200);
const people = response.body.data.people;
const pageInfo = response.body.pageInfo;
expect(people).toBeDefined();
expect(people.length).toBeLessThanOrEqual(2);
expect(pageInfo).toBeDefined();
expect(people).toEqual(
[...people].sort((a, b) => a.jobTitle.localeCompare(b.jobTitle)),
);
});
it('should should throw an error when trying to order by a composite field', async () => {
await makeRestAPIRequest({
method: 'get',
path: '/people?order_by=name[AscNullsLast]',
}).expect(400);
});
// TODO: Uncomment this test when we support composite fields ordering in the rest api
// const testPeople = [
// {
// id: TEST_PERSON_1_ID,
// firstName: 'Alice',
// lastName: 'Brown',
// position: 0,
// },
// {
// id: TEST_PERSON_2_ID,
// firstName: 'Alice',
// lastName: 'Smith',
// position: 1,
// },
// {
// id: TEST_PERSON_3_ID,
// firstName: 'Bob',
// lastName: 'Johnson',
// position: 2,
// },
// {
// id: TEST_PERSON_4_ID,
// firstName: 'Bob',
// lastName: 'Williams',
// position: 3,
// },
// {
// id: TEST_PERSON_5_ID,
// firstName: 'Charlie',
// lastName: 'Davis',
// position: 4,
// },
// ];
it('should support depth 0 parameter', async () => {
const response = await makeRestAPIRequest({
method: 'get',
path: '/people?depth=0',
}).expect(200);
const people = response.body.data.people;
expect(people).toBeDefined();
const person = people[0];
expect(person).toBeDefined();
expect(person.companyId).toBeDefined();
expect(person.company).not.toBeDefined();
});
it('should support depth 1 parameter', async () => {
const response = await makeRestAPIRequest({
method: 'get',
path: '/people?depth=1',
}).expect(200);
const people = response.body.data.people;
const person = people[0];
expect(person.company).toBeDefined();
expect(person.company.domainName.primaryLinkUrl).toBe(
TEST_PRIMARY_LINK_URL_AS_DOMAIN,
);
expect(person.company.people).not.toBeDefined();
});
it('should not support depth 2 parameter', async () => {
await makeRestAPIRequest({
method: 'get',
path: '/people?depth=2',
}).expect(400);
});
describe('cursor pagination ordered by a relation field', () => {
it('should continue past the first page when depth loads the ordered relation', async () => {
const ids: string[] = [];
let startingAfter: string | undefined = undefined;
for (let iteration = 0; iteration < 10; iteration++) {
const path: string =
'/people?order_by=company.name[AscNullsLast]&limit=2&depth=1' +
(startingAfter ? `&starting_after=${startingAfter}` : '');
const response = await makeRestAPIRequest({
method: 'get',
path,
}).expect(200);
ids.push(
...response.body.data.people.map(
(person: { id: string }) => person.id,
),
);
if (!response.body.pageInfo.hasNextPage) {
break;
}
startingAfter = response.body.pageInfo.endCursor;
}
expect(ids).toHaveLength(testPersonIds.length);
expect(new Set(ids).size).toBe(testPersonIds.length);
});
// Cursors read relation orderBy values from the ordering join itself, so
// continuation must not require any depth (issue #24333)
it('should continue past the first page at depth 0', async () => {
const ids: string[] = [];
let startingAfter: string | undefined = undefined;
for (let iteration = 0; iteration < 10; iteration++) {
const path: string =
'/people?order_by=company.name[AscNullsLast]&limit=2&depth=0' +
(startingAfter ? `&starting_after=${startingAfter}` : '');
const response = await makeRestAPIRequest({
method: 'get',
path,
}).expect(200);
ids.push(
...response.body.data.people.map(
(person: { id: string }) => person.id,
),
);
if (!response.body.pageInfo.hasNextPage) {
break;
}
startingAfter = response.body.pageInfo.endCursor;
}
expect(ids).toHaveLength(testPersonIds.length);
expect(new Set(ids).size).toBe(testPersonIds.length);
});
});
describe('cursor pagination ordered by a nullable field', () => {
const datedOpportunityCloseDates = [
'2026-03-01T00:00:00.000Z',
'2026-03-02T00:00:00.000Z',
'2026-03-03T00:00:00.000Z',
];
const opportunityCount = datedOpportunityCloseDates.length + 2;
beforeAll(async () => {
await deleteAllRecords('opportunity');
for (const [index, closeDate] of datedOpportunityCloseDates.entries()) {
await makeRestAPIRequest({
method: 'post',
path: '/opportunities',
body: { name: `REST dated opportunity ${index + 1}`, closeDate },
});
}
for (const index of [1, 2]) {
await makeRestAPIRequest({
method: 'post',
path: '/opportunities',
body: { name: `REST undated opportunity ${index}` },
});
}
});
afterAll(async () => {
await deleteAllRecords('opportunity');
});
it('should return every record when paginating with starting_after', async () => {
const ids: string[] = [];
let startingAfter: string | undefined = undefined;
for (let iteration = 0; iteration < 10; iteration++) {
const path: string =
'/opportunities?order_by=closeDate[AscNullsLast]&limit=2' +
(startingAfter ? `&starting_after=${startingAfter}` : '');
const response = await makeRestAPIRequest({
method: 'get',
path,
}).expect(200);
ids.push(
...response.body.data.opportunities.map(
(opportunity: { id: string }) => opportunity.id,
),
);
if (!response.body.pageInfo.hasNextPage) {
break;
}
startingAfter = response.body.pageInfo.endCursor;
}
expect(ids).toHaveLength(opportunityCount);
expect(new Set(ids).size).toBe(opportunityCount);
});
});
});

View File

@@ -0,0 +1,123 @@
import { TEST_COMPANY_1_ID } from 'test/integration/constants/test-company-ids.constants';
import {
NOT_EXISTING_TEST_PERSON_ID,
TEST_PERSON_1_ID,
} from 'test/integration/constants/test-person-ids.constants';
import {
TEST_PRIMARY_LINK_URL,
TEST_PRIMARY_LINK_URL_AS_DOMAIN,
} from 'test/integration/constants/test-primary-link-url.constant';
import { makeRestAPIRequest } from 'test/integration/rest/utils/make-rest-api-request.util';
import { deleteAllRecords } from 'test/integration/utils/delete-all-records';
import { generateRecordName } from 'test/integration/utils/generate-record-name';
describe('Core REST API Find One endpoint', () => {
let personJobTitle: string;
beforeAll(async () => {
await deleteAllRecords('person');
await deleteAllRecords('company');
personJobTitle = generateRecordName(TEST_PERSON_1_ID);
await makeRestAPIRequest({
method: 'post',
path: '/companies',
body: {
id: TEST_COMPANY_1_ID,
domainName: {
primaryLinkUrl: TEST_PRIMARY_LINK_URL,
},
},
});
await makeRestAPIRequest({
method: 'post',
path: '/people',
body: {
id: TEST_PERSON_1_ID,
jobTitle: personJobTitle,
companyId: TEST_COMPANY_1_ID,
},
});
});
it('should retrieve a person by ID', async () => {
await makeRestAPIRequest({
method: 'get',
path: `/people/${TEST_PERSON_1_ID}`,
})
.expect(200)
.expect((res) => {
const person = res.body.data.person;
expect(person).not.toBeNull();
expect(person.id).toBe(TEST_PERSON_1_ID);
expect(person.jobTitle).toBe(personJobTitle);
});
});
it('should return 404 error when trying to retrieve a non-existing person', async () => {
const response = await makeRestAPIRequest({
method: 'get',
path: `/people/${NOT_EXISTING_TEST_PERSON_ID}`,
});
expect(response.status).toBe(404);
expect(response.body.error).toBe('NotFoundException');
expect(response.body.messages[0]).toBe('Record not found');
});
it('should return 400 error when trying to retrieve with malformed uuid', async () => {
await makeRestAPIRequest({
method: 'get',
path: `/people/malformed-uuid`,
})
.expect(400)
.expect((res) => {
expect(res.body.messages[0]).toContain(
"'malformed-uuid' is not a valid UUID",
);
expect(res.body.error).toBe('BadRequestException');
});
});
it('should support depth 0 parameter', async () => {
await makeRestAPIRequest({
method: 'get',
path: `/people/${TEST_PERSON_1_ID}?depth=0`,
})
.expect(200)
.expect((res) => {
const person = res.body.data.person;
expect(person).toBeDefined();
expect(person.companyId).toBeDefined();
expect(person.company).not.toBeDefined();
});
});
it('should support depth 1 parameter', async () => {
await makeRestAPIRequest({
method: 'get',
path: `/people/${TEST_PERSON_1_ID}?depth=1`,
})
.expect(200)
.expect((res) => {
const person = res.body.data.person;
expect(person.company).toBeDefined();
expect(person.company.domainName.primaryLinkUrl).toBe(
TEST_PRIMARY_LINK_URL_AS_DOMAIN,
);
expect(person.company.people).not.toBeDefined();
});
});
it('should not support depth 2 parameter', async () => {
await makeRestAPIRequest({
method: 'get',
path: `/people/${TEST_PERSON_1_ID}?depth=2`,
}).expect(400);
});
});

View File

@@ -0,0 +1,356 @@
import { randomUUID } from 'crypto';
import { COMPANY_GQL_FIELDS } from 'test/integration/constants/company-gql-fields.constants';
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 { makeRestAPIRequest } from 'test/integration/rest/utils/make-rest-api-request.util';
const OPPORTUNITY_GQL_FIELDS = `
id
stage
amount {
amountMicros
}
companyId
createdAt
closeDate
`;
// used not to mix records with the seeded ones
const FILTER_2020 =
"createdAt[gte]:'2020-01-01T00:00:00.000Z',createdAt[lte]:'2020-03-03T23:59:59.999Z'";
const AGGREGATE_FIELDS = '["maxAmountAmountMicros"]';
describe('REST API Core Group By endpoint', () => {
const testOpportunityId1 = randomUUID();
const testOpportunityId2 = randomUUID();
const testOpportunityId3 = randomUUID();
const testOpportunityId4 = randomUUID();
const testCompanyId1 = randomUUID();
const testCompanyId2 = randomUUID();
const COMPANY_1_POSITION = 10;
const COMPANY_2_POSITION = 20;
beforeAll(async () => {
await makeGraphqlAPIRequest(
createOneOperationFactory({
objectMetadataSingularName: 'company',
gqlFields: COMPANY_GQL_FIELDS,
data: {
id: testCompanyId1,
name: 'Company 1',
position: COMPANY_1_POSITION,
createdAt: '2020-02-05T08:00:00.000Z',
},
}),
);
await makeGraphqlAPIRequest(
createOneOperationFactory({
objectMetadataSingularName: 'company',
gqlFields: COMPANY_GQL_FIELDS,
data: {
id: testCompanyId2,
name: 'Company 2',
position: COMPANY_2_POSITION,
createdAt: '2020-02-05T08:00:00.000Z',
},
}),
);
// Create test opportunities with different stages and dates
await makeGraphqlAPIRequest(
createOneOperationFactory({
objectMetadataSingularName: 'opportunity',
gqlFields: OPPORTUNITY_GQL_FIELDS,
data: {
id: testOpportunityId1,
stage: 'NEW',
name: 'Opportunity 1',
amount: { amountMicros: 1000000000000 }, // 1000
companyId: testCompanyId1,
closeDate: '2025-02-05T08:00:00.000Z', // Wednesday
createdAt: '2020-02-05T08:00:00.000Z',
},
}),
);
await makeGraphqlAPIRequest(
createOneOperationFactory({
objectMetadataSingularName: 'opportunity',
gqlFields: OPPORTUNITY_GQL_FIELDS,
data: {
id: testOpportunityId2,
stage: 'NEW',
name: 'Opportunity 2',
amount: { amountMicros: 2000000000000 }, // 2000
companyId: testCompanyId1,
closeDate: '2025-02-06T08:00:00.000Z', // Thursday
createdAt: '2020-02-05T08:00:00.000Z',
},
}),
);
await makeGraphqlAPIRequest(
createOneOperationFactory({
objectMetadataSingularName: 'opportunity',
gqlFields: OPPORTUNITY_GQL_FIELDS,
data: {
id: testOpportunityId3,
stage: 'NEW',
name: 'Opportunity 3',
amount: { amountMicros: 3000000000000 }, // 3000
companyId: testCompanyId2,
closeDate: '2025-02-06T08:00:00.000Z', // Thursday
createdAt: '2020-02-05T08:00:00.000Z',
},
}),
);
await makeGraphqlAPIRequest(
createOneOperationFactory({
objectMetadataSingularName: 'opportunity',
gqlFields: OPPORTUNITY_GQL_FIELDS,
data: {
id: testOpportunityId4,
stage: 'SCREENING',
name: 'Opportunity 4',
amount: { amountMicros: 4000000000000 }, // 4000
companyId: testCompanyId2,
closeDate: '2025-02-06T08:00:00.000Z', // Thursday
createdAt: '2020-02-05T08:00:00.000Z',
},
}),
);
});
afterAll(async () => {
for (const id of [
testOpportunityId1,
testOpportunityId2,
testOpportunityId3,
testOpportunityId4,
]) {
await makeGraphqlAPIRequest(
destroyOneOperationFactory({
objectMetadataSingularName: 'opportunity',
gqlFields: 'id',
recordId: id,
}),
);
}
for (const id of [testCompanyId1, testCompanyId2]) {
await makeGraphqlAPIRequest(
destroyOneOperationFactory({
objectMetadataSingularName: 'company',
gqlFields: 'id',
recordId: id,
}),
);
}
});
it('groups by stage and closeDate with records', async () => {
const groupByQuery = JSON.stringify([
{
closeDate: {
granularity: 'DAY_OF_THE_WEEK',
},
},
{
stage: true,
},
]);
const response = await makeRestAPIRequest({
method: 'get',
path: `/opportunities/groupBy?group_by=${encodeURIComponent(groupByQuery)}&aggregate=${encodeURIComponent(AGGREGATE_FIELDS)}&filter=${encodeURIComponent(FILTER_2020)}&include_records_sample=true&limit=3`,
body: {},
});
expect(response.status).toBe(200);
expect(response.body).toBeDefined();
const groups = response.body;
expect(groups).toBeDefined();
expect(groups).toHaveLength(3);
groups.forEach((group: any) => {
expect(group.groupByDimensionValues).toBeDefined();
expect(Array.isArray(group.groupByDimensionValues)).toBe(true);
expect(group.records).toBeDefined();
expect(Array.isArray(group.records)).toBe(true);
});
const wednesdayNewGroup = groups.find(
(group: any) =>
group.groupByDimensionValues.includes('Wednesday') &&
group.groupByDimensionValues.includes('NEW'),
);
expect(wednesdayNewGroup).toBeDefined();
expect(wednesdayNewGroup.maxAmountAmountMicros).toBe('1000000000000');
expect(wednesdayNewGroup.records).toHaveLength(1);
expect(wednesdayNewGroup.records[0].name).toBe('Opportunity 1');
expect(wednesdayNewGroup.records[0].stage).toBe('NEW');
const thursdayNewGroup = groups.find(
(group: any) =>
group.groupByDimensionValues.includes('Thursday') &&
group.groupByDimensionValues.includes('NEW'),
);
expect(thursdayNewGroup).toBeDefined();
expect(thursdayNewGroup.records).toHaveLength(2);
expect(thursdayNewGroup.maxAmountAmountMicros).toBe('3000000000000');
const opportunity2Record = thursdayNewGroup.records.find(
(record: any) => record.name === 'Opportunity 2',
);
const opportunity3Record = thursdayNewGroup.records.find(
(record: any) => record.name === 'Opportunity 3',
);
expect(opportunity2Record.stage).toBe('NEW');
expect(opportunity2Record.name).toBe('Opportunity 2');
expect(opportunity2Record.companyId).toBe(testCompanyId1);
expect(opportunity3Record.stage).toBe('NEW');
expect(opportunity3Record.name).toBe('Opportunity 3');
expect(opportunity3Record.companyId).toBe(testCompanyId2);
const thursdayScreeningGroup = groups.find(
(group: any) =>
group.groupByDimensionValues.includes('Thursday') &&
group.groupByDimensionValues.includes('SCREENING'),
);
expect(thursdayScreeningGroup).toBeDefined();
expect(thursdayScreeningGroup.records).toHaveLength(1);
const opportunity4Record = thursdayScreeningGroup.records[0];
expect(opportunity4Record.stage).toBe('SCREENING');
expect(opportunity4Record.name).toBe('Opportunity 4');
expect(opportunity4Record.companyId).toBe(testCompanyId2);
expect(thursdayScreeningGroup.maxAmountAmountMicros).toBe('4000000000000');
});
it('groups by stage and closeDate with records and filters', async () => {
const groupByQuery = JSON.stringify([
{
closeDate: {
granularity: 'DAY_OF_THE_WEEK',
},
},
{
stage: true,
},
]);
const filterQuery = `${FILTER_2020},stage[eq]:'NEW'`;
const response = await makeRestAPIRequest({
method: 'get',
path: `/opportunities/groupBy?group_by=${encodeURIComponent(groupByQuery)}&aggregate=${encodeURIComponent(AGGREGATE_FIELDS)}&filter=${encodeURIComponent(filterQuery)}&include_records_sample=true&limit=2`,
body: {},
});
expect(response.status).toBe(200);
expect(response.body).toBeDefined();
const groups = response.body;
expect(groups).toHaveLength(2);
const wednesdayNewGroup = groups.find(
(group: any) =>
group.groupByDimensionValues.includes('Wednesday') &&
group.groupByDimensionValues.includes('NEW'),
);
expect(wednesdayNewGroup.groupByDimensionValues).toHaveLength(2);
expect(wednesdayNewGroup.groupByDimensionValues).toContain('NEW');
expect(wednesdayNewGroup.groupByDimensionValues).toContain('Wednesday');
expect(wednesdayNewGroup.records).toHaveLength(1);
expect(wednesdayNewGroup.records[0].stage).toBe('NEW');
const thursdayNewGroup = groups.find(
(group: any) =>
group.groupByDimensionValues.includes('Thursday') &&
group.groupByDimensionValues.includes('NEW'),
);
expect(thursdayNewGroup.groupByDimensionValues).toHaveLength(2);
expect(thursdayNewGroup.groupByDimensionValues).toContain('NEW');
expect(thursdayNewGroup.groupByDimensionValues).toContain('Thursday');
expect(thursdayNewGroup.records).toHaveLength(2);
});
describe('order by for records', () => {
const getGroupByRequestWithOrderByForRecords = (
orderByForRecords: string,
) => {
const groupByQuery = JSON.stringify([
{
closeDate: {
granularity: 'DAY_OF_THE_WEEK',
},
},
{
stage: true,
},
]);
return {
method: 'get' as const,
path: `/opportunities/groupBy?group_by=${encodeURIComponent(groupByQuery)}&filter=${encodeURIComponent(FILTER_2020)}&order_by_for_records=${encodeURIComponent(`name[${orderByForRecords}]`)}&include_records_sample=true&limit=5`,
body: {},
};
};
it('sorts by name in ascending order', async () => {
const response = await makeRestAPIRequest(
getGroupByRequestWithOrderByForRecords('AscNullsFirst'),
);
expect(response.status).toBe(200);
expect(response.body).toBeDefined();
const groups = response.body;
const thursdayNewGroup = groups.find(
(group: any) =>
group.groupByDimensionValues.includes('Thursday') &&
group.groupByDimensionValues.includes('NEW'),
);
expect(thursdayNewGroup).toBeDefined();
expect(thursdayNewGroup.records).toHaveLength(2);
expect(thursdayNewGroup.records[0].name).toBe('Opportunity 2');
expect(thursdayNewGroup.records[1].name).toBe('Opportunity 3');
});
it('sorts by name in descending order', async () => {
const response = await makeRestAPIRequest(
getGroupByRequestWithOrderByForRecords('DescNullsFirst'),
);
expect(response.status).toBe(200);
expect(response.body).toBeDefined();
const groups = response.body;
const thursdayNewGroup = groups.find(
(group: any) =>
group.groupByDimensionValues.includes('Thursday') &&
group.groupByDimensionValues.includes('NEW'),
);
expect(thursdayNewGroup).toBeDefined();
expect(thursdayNewGroup.records).toHaveLength(2);
expect(thursdayNewGroup.records[0].name).toBe('Opportunity 3');
expect(thursdayNewGroup.records[1].name).toBe('Opportunity 2');
});
});
});

View File

@@ -0,0 +1,65 @@
import {
TEST_PERSON_1_ID,
TEST_PERSON_2_ID,
} from 'test/integration/constants/test-person-ids.constants';
import { makeRestAPIRequest } from 'test/integration/rest/utils/make-rest-api-request.util';
import { deleteAllRecords } from 'test/integration/utils/delete-all-records';
describe('Core REST API Merge endpoint', () => {
beforeEach(async () => {
await deleteAllRecords('person');
await makeRestAPIRequest({
method: 'post',
path: '/batch/people',
body: [{ id: TEST_PERSON_1_ID }, { id: TEST_PERSON_2_ID }],
});
});
it('should merge many people into the priority record', async () => {
await makeRestAPIRequest({
method: 'patch',
path: '/people/merge',
body: {
ids: [TEST_PERSON_1_ID, TEST_PERSON_2_ID],
conflictPriorityIndex: 0,
dryRun: false,
},
})
.expect(200)
.expect((res) => {
expect(res.body.data.mergePerson.id).toBe(TEST_PERSON_1_ID);
});
await makeRestAPIRequest({
method: 'get',
path: `/people/${TEST_PERSON_1_ID}`,
}).expect(200);
await makeRestAPIRequest({
method: 'get',
path: `/people/${TEST_PERSON_2_ID}`,
}).expect(404);
});
it('should not delete records on a dry run merge', async () => {
await makeRestAPIRequest({
method: 'patch',
path: '/people/merge',
body: {
ids: [TEST_PERSON_1_ID, TEST_PERSON_2_ID],
conflictPriorityIndex: 0,
dryRun: true,
},
}).expect(200);
await makeRestAPIRequest({
method: 'get',
path: `/people/${TEST_PERSON_1_ID}`,
}).expect(200);
await makeRestAPIRequest({
method: 'get',
path: `/people/${TEST_PERSON_2_ID}`,
}).expect(200);
});
});

View File

@@ -0,0 +1,118 @@
import { TEST_COMPANY_1_ID } from 'test/integration/constants/test-company-ids.constants';
import {
NOT_EXISTING_TEST_PERSON_ID,
TEST_PERSON_1_ID,
} from 'test/integration/constants/test-person-ids.constants';
import { TEST_PRIMARY_LINK_URL } from 'test/integration/constants/test-primary-link-url.constant';
import { makeRestAPIRequest } from 'test/integration/rest/utils/make-rest-api-request.util';
import { deleteAllRecords } from 'test/integration/utils/delete-all-records';
import { generateRecordName } from 'test/integration/utils/generate-record-name';
describe('Core REST API Update One endpoint', () => {
const updatedData = {
name: {
firstName: 'Updated',
lastName: 'Person',
},
emails: {
primaryEmail: 'updated@example.com',
additionalEmails: ['extra@example.com'],
},
jobTitle: generateRecordName(TEST_PERSON_1_ID),
};
beforeAll(async () => {
await deleteAllRecords('person');
await makeRestAPIRequest({
method: 'post',
path: '/companies',
body: {
id: TEST_COMPANY_1_ID,
domainName: {
primaryLinkUrl: TEST_PRIMARY_LINK_URL,
},
},
});
await makeRestAPIRequest({
method: 'post',
path: `/people`,
body: {
id: TEST_PERSON_1_ID,
companyId: TEST_COMPANY_1_ID,
},
});
});
it('should update an existing person (name, emails, and jobTitle)', async () => {
const response = await makeRestAPIRequest({
method: 'patch',
path: `/people/${TEST_PERSON_1_ID}`,
body: updatedData,
});
expect(response.status).toBe(200);
const updatedPerson = response.body.data.updatePerson;
expect(updatedPerson.id).toBe(TEST_PERSON_1_ID);
expect(updatedPerson.name.firstName).toBe(updatedData.name.firstName);
expect(updatedPerson.name.lastName).toBe(updatedData.name.lastName);
expect(updatedPerson.emails.primaryEmail).toBe(
updatedData.emails.primaryEmail,
);
expect(updatedPerson.emails.additionalEmails).toEqual(
updatedData.emails.additionalEmails,
);
expect(updatedPerson.jobTitle).toBe(updatedData.jobTitle);
expect(updatedPerson.companyId).toBe(TEST_COMPANY_1_ID);
});
it('should support depth 0 parameter', async () => {
await makeRestAPIRequest({
method: 'patch',
path: `/people/${TEST_PERSON_1_ID}?depth=0`,
body: updatedData,
})
.expect(200)
.expect((res) => {
const updatedPerson = res.body.data.updatePerson;
expect(updatedPerson.companyId).toBeDefined();
expect(updatedPerson.company).not.toBeDefined();
});
});
it('should support depth 1 parameter', async () => {
await makeRestAPIRequest({
method: 'patch',
path: `/people/${TEST_PERSON_1_ID}?depth=1`,
body: updatedData,
})
.expect(200)
.expect((res) => {
const updatedPerson = res.body.data.updatePerson;
expect(updatedPerson.company).toBeDefined();
expect(updatedPerson.company.people).not.toBeDefined();
});
});
it('should support depth 2 parameter', async () => {
await makeRestAPIRequest({
method: 'patch',
path: `/people/${TEST_PERSON_1_ID}?depth=2`,
body: updatedData,
}).expect(400);
});
it('should return a EntityNotFoundError when trying to update a non-existing person', async () => {
const response = await makeRestAPIRequest({
method: 'patch',
path: `/people/${NOT_EXISTING_TEST_PERSON_ID}`,
});
expect(response.status).toBe(404);
expect(response.body.error).toBe('NotFoundException');
expect(response.body.messages[0]).toBe('Record not found');
});
});

View File

@@ -0,0 +1,182 @@
import { randomUUID } from 'crypto';
import gql from 'graphql-tag';
import request from 'supertest';
import { createCustomRoleWithObjectPermissions } from 'test/integration/graphql/utils/create-custom-role-with-object-permissions.util';
import { createOneOperationFactory } from 'test/integration/graphql/utils/create-one-operation-factory.util';
import { deleteRole } from 'test/integration/graphql/utils/delete-one-role.util';
import { makeGraphqlAPIRequest } from 'test/integration/graphql/utils/make-graphql-api-request.util';
import { updateWorkspaceMemberRole } from 'test/integration/graphql/utils/update-workspace-member-role.util';
import { upsertFieldPermissions } from 'test/integration/graphql/utils/upsert-field-permissions.util';
import { makeMetadataAPIRequest } from 'test/integration/metadata/suites/utils/make-metadata-api-request.util';
import { makeRestAPIRequest } from 'test/integration/rest/utils/make-rest-api-request.util';
import { deleteRecordsByIds } from 'test/integration/utils/delete-records-by-ids';
import { WORKSPACE_MEMBER_DATA_SEED_IDS } from 'src/engine/workspace-manager/dev-seeder/data/constants/workspace-member-data-seeds.constant';
const client = request(`http://localhost:${APP_PORT}`);
describe('REST depth=1 with a restricted relation field', () => {
let companyId: string;
let personId: string;
let customRoleId: string;
let companyObjectId: string;
let peopleFieldId: string;
let originalMemberRoleId: string;
beforeAll(async () => {
const rolesResponse = await client
.post('/metadata')
.set('Authorization', `Bearer ${APPLE_JANE_ADMIN_ACCESS_TOKEN}`)
.send({
query: `
query GetRoles {
getRoles {
id
label
}
}
`,
});
originalMemberRoleId = rolesResponse.body.data.getRoles.find(
(role: { label: string }) => role.label === 'Member',
).id;
companyId = randomUUID();
personId = randomUUID();
await makeGraphqlAPIRequest(
createOneOperationFactory({
objectMetadataSingularName: 'company',
gqlFields: 'id name',
data: { id: companyId, name: 'RestDepthPermissionCompany' },
}),
);
await makeGraphqlAPIRequest(
createOneOperationFactory({
objectMetadataSingularName: 'person',
gqlFields: 'id',
data: { id: personId, companyId },
}),
);
const objectMetadataResponse = await makeMetadataAPIRequest({
query: gql`
query {
objects(paging: { first: 1000 }) {
edges {
node {
id
nameSingular
}
}
}
}
`,
});
companyObjectId = objectMetadataResponse.body.data.objects.edges.find(
(edge: { node: { nameSingular: string } }) =>
edge.node.nameSingular === 'company',
).node.id;
const fieldMetadataResponse = await makeMetadataAPIRequest({
query: gql`
query {
fields(paging: { first: 1000 }) {
edges {
node {
id
name
object {
nameSingular
}
}
}
}
}
`,
});
peopleFieldId = fieldMetadataResponse.body.data.fields.edges.find(
(edge: { node: { name: string; object: { nameSingular: string } } }) =>
edge.node.name === 'people' &&
edge.node.object.nameSingular === 'company',
).node.id;
});
afterAll(async () => {
await deleteRecordsByIds('person', [personId]);
await deleteRecordsByIds('company', [companyId]);
});
beforeEach(async () => {
const { roleId } = await createCustomRoleWithObjectPermissions({
label: 'RestDepthRelationRole',
canReadCompany: true,
canReadPerson: true,
});
customRoleId = roleId;
await updateWorkspaceMemberRole({
client,
roleId: customRoleId,
workspaceMemberId: WORKSPACE_MEMBER_DATA_SEED_IDS.JONY,
});
});
afterEach(async () => {
await updateWorkspaceMemberRole({
client,
roleId: originalMemberRoleId,
workspaceMemberId: WORKSPACE_MEMBER_DATA_SEED_IDS.JONY,
});
if (customRoleId) {
await deleteRole(client, customRoleId);
customRoleId = '';
}
});
it('should omit the entire relation when the relation field is restricted for the requesting role', async () => {
await upsertFieldPermissions({
roleId: customRoleId,
fieldPermissions: [
{
objectMetadataId: companyObjectId,
fieldMetadataId: peopleFieldId,
canReadFieldValue: false,
},
],
});
const response = await makeRestAPIRequest({
method: 'get',
path: `/companies/${companyId}?depth=1`,
bearer: APPLE_JONY_MEMBER_ACCESS_TOKEN,
}).expect(200);
const company = response.body.data.company;
expect(company.id).toBe(companyId);
expect(company).not.toHaveProperty('people');
});
it('should include related records when the relation field is not restricted', async () => {
const response = await makeRestAPIRequest({
method: 'get',
path: `/companies/${companyId}?depth=1`,
bearer: APPLE_JONY_MEMBER_ACCESS_TOKEN,
}).expect(200);
const company = response.body.data.company;
expect(company.id).toBe(companyId);
expect(company.people).toEqual(
expect.arrayContaining([expect.objectContaining({ id: personId })]),
);
});
});

View File

@@ -0,0 +1,351 @@
import { createOneFieldMetadata } from 'test/integration/metadata/suites/field-metadata/utils/create-one-field-metadata.util';
import { createOneObjectMetadata } from 'test/integration/metadata/suites/object-metadata/utils/create-one-object-metadata.util';
import { deleteOneObjectMetadata } from 'test/integration/metadata/suites/object-metadata/utils/delete-one-object-metadata.util';
import { updateOneObjectMetadata } from 'test/integration/metadata/suites/object-metadata/utils/update-one-object-metadata.util';
import { makeRestAPIRequest } from 'test/integration/rest/utils/make-rest-api-request.util';
import {
assertMetadataRestListResponse,
assertRestApiErrorNotFoundResponse,
assertRestApiErrorResponse,
assertRestApiSuccessfulResponse,
} from 'test/integration/rest/utils/rest-test-assertions.util';
import {
createTestViewFieldWithRestApi,
createTestViewWithRestApi,
} from 'test/integration/rest/utils/view-rest-api.util';
import { assertViewFieldStructure } from 'test/integration/utils/view-test.util';
import { jestExpectToBeDefined } from 'test/utils/jest-expect-to-be-defined.util.test';
import { FieldMetadataType } from 'twenty-shared/types';
import { destroyOneViewField } from 'test/integration/metadata/suites/view-field/utils/destroy-one-view-field.util';
import { type ViewFieldDTO } from 'src/engine/metadata-modules/view-field/dtos/view-field.dto';
import {
generateViewFieldExceptionMessage,
ViewFieldExceptionMessageKey,
} from 'src/engine/metadata-modules/view-field/exceptions/view-field.exception';
describe('View Field REST API', () => {
let testObjectMetadataId: string;
let testFieldMetadataId: string;
let testViewId: string;
let testViewFieldId: string | undefined;
beforeAll(async () => {
const {
data: {
createOneObject: { id: objectMetadataId },
},
} = await createOneObjectMetadata({
expectToFail: false,
input: {
nameSingular: 'testViewFieldObject',
namePlural: 'testViewFieldObjects',
labelSingular: 'Test View Field Object',
labelPlural: 'Test View Field Objects',
icon: 'IconField',
},
});
testObjectMetadataId = objectMetadataId;
const createFieldInput = {
name: 'testField',
label: 'Test Field',
type: FieldMetadataType.TEXT,
objectMetadataId: testObjectMetadataId,
isLabelSyncedWithName: true,
};
const {
data: {
createOneField: { id: fieldMetadataId },
},
} = await createOneFieldMetadata({
expectToFail: false,
input: createFieldInput,
gqlFields: `
id
name
label
isLabelSyncedWithName
`,
});
testFieldMetadataId = fieldMetadataId;
const testView = await createTestViewWithRestApi({
name: 'Test View for Field Integration',
objectMetadataId: testObjectMetadataId,
});
testViewId = testView.id;
});
afterAll(async () => {
await updateOneObjectMetadata({
expectToFail: false,
input: {
idToUpdate: testObjectMetadataId,
updatePayload: {
isActive: false,
},
},
});
await deleteOneObjectMetadata({
expectToFail: false,
input: { idToDelete: testObjectMetadataId },
});
});
afterEach(async () => {
if (!testViewFieldId) return;
await destroyOneViewField({
expectToFail: false,
input: {
id: testViewFieldId,
},
});
testViewFieldId = undefined;
});
describe('GET /metadata/viewFields', () => {
it('should return empty array when no view fields exist', async () => {
const response = await makeRestAPIRequest({
method: 'get',
path: `/metadata/viewFields?viewId=${testViewId}`,
bearer: APPLE_JANE_ADMIN_ACCESS_TOKEN,
});
expect(assertMetadataRestListResponse<ViewFieldDTO>(response)).toEqual(
[],
);
expect(response.body.totalCount).toBe(0);
});
it('should return all view fields for workspace when no viewId provided', async () => {
const response = await makeRestAPIRequest({
method: 'get',
path: '/metadata/viewFields',
bearer: APPLE_JANE_ADMIN_ACCESS_TOKEN,
});
assertMetadataRestListResponse<ViewFieldDTO>(response);
});
it('should return view fields for a specific view after creating one', async () => {
const viewField = await createTestViewFieldWithRestApi({
viewId: testViewId,
fieldMetadataId: testFieldMetadataId,
position: 0,
isVisible: true,
size: 150,
});
testViewFieldId = viewField.id;
const response = await makeRestAPIRequest({
method: 'get',
path: `/metadata/viewFields?viewId=${testViewId}`,
bearer: APPLE_JANE_ADMIN_ACCESS_TOKEN,
});
const viewFields = assertMetadataRestListResponse<ViewFieldDTO>(response);
const returnedViewField = viewFields.find(
(el: ViewFieldDTO) => el.id === viewField.id,
);
jestExpectToBeDefined(returnedViewField);
assertViewFieldStructure(returnedViewField, {
id: viewField.id,
fieldMetadataId: testFieldMetadataId,
viewId: testViewId,
position: 0,
isVisible: true,
size: 150,
});
});
});
describe('POST /metadata/viewFields', () => {
it('should create a new view field', async () => {
const viewField = await createTestViewFieldWithRestApi({
viewId: testViewId,
fieldMetadataId: testFieldMetadataId,
position: 1,
isVisible: true,
size: 200,
});
testViewFieldId = viewField.id;
assertViewFieldStructure(viewField, {
fieldMetadataId: testFieldMetadataId,
viewId: testViewId,
position: 1,
isVisible: true,
size: 200,
});
});
it('should create a hidden view field', async () => {
const hiddenField = await createTestViewFieldWithRestApi({
viewId: testViewId,
fieldMetadataId: testFieldMetadataId,
position: 2,
isVisible: false,
size: 100,
});
testViewFieldId = hiddenField.id;
assertViewFieldStructure(hiddenField, {
fieldMetadataId: testFieldMetadataId,
viewId: testViewId,
position: 2,
isVisible: false,
size: 100,
});
});
});
describe('GET /metadata/viewFields/:id', () => {
it('should return a view field by id', async () => {
const viewField = await createTestViewFieldWithRestApi({
viewId: testViewId,
fieldMetadataId: testFieldMetadataId,
position: 0,
isVisible: true,
size: 150,
});
testViewFieldId = viewField.id;
const response = await makeRestAPIRequest({
method: 'get',
path: `/metadata/viewFields/${viewField.id}`,
bearer: APPLE_JANE_ADMIN_ACCESS_TOKEN,
});
assertRestApiSuccessfulResponse(response);
assertViewFieldStructure(response.body, {
id: viewField.id,
fieldMetadataId: testFieldMetadataId,
viewId: testViewId,
});
});
it('should return empty object for non-existent view field', async () => {
const response = await makeRestAPIRequest({
method: 'get',
path: `/metadata/viewFields/20202020-f891-4d2a-8b23-c1e4d7f6a9b2`,
bearer: APPLE_JANE_ADMIN_ACCESS_TOKEN,
});
assertRestApiErrorNotFoundResponse(response);
});
});
describe('PATCH /metadata/viewFields/:id', () => {
it('should update an existing view field', async () => {
const viewField = await createTestViewFieldWithRestApi({
viewId: testViewId,
fieldMetadataId: testFieldMetadataId,
position: 0,
isVisible: true,
size: 150,
});
testViewFieldId = viewField.id;
const updateData = {
position: 5,
isVisible: false,
size: 300,
};
const response = await makeRestAPIRequest({
method: 'patch',
path: `/metadata/viewFields/${viewField.id}`,
body: updateData,
bearer: APPLE_JANE_ADMIN_ACCESS_TOKEN,
});
assertRestApiSuccessfulResponse(response);
assertViewFieldStructure(response.body, {
id: viewField.id,
position: 5,
isVisible: false,
size: 300,
fieldMetadataId: testFieldMetadataId,
viewId: testViewId,
});
});
it('should return 404 error when updating non-existent view field', async () => {
const updateData = {
position: 5,
isVisible: false,
size: 300,
};
const response = await makeRestAPIRequest({
method: 'patch',
path: `/metadata/viewFields/20202020-f891-4d2a-8b23-c1e4d7f6a9b2`,
body: updateData,
bearer: APPLE_JANE_ADMIN_ACCESS_TOKEN,
});
assertRestApiErrorResponse(
response,
404,
generateViewFieldExceptionMessage(
ViewFieldExceptionMessageKey.VIEW_FIELD_NOT_FOUND,
'20202020-f891-4d2a-8b23-c1e4d7f6a9b2',
),
);
});
});
describe('DELETE /metadata/viewFields/:id', () => {
it('should delete an existing view field', async () => {
const viewField = await createTestViewFieldWithRestApi({
viewId: testViewId,
fieldMetadataId: testFieldMetadataId,
position: 0,
isVisible: true,
size: 150,
});
testViewFieldId = viewField.id;
const deleteResponse = await makeRestAPIRequest({
method: 'delete',
path: `/metadata/viewFields/${viewField.id}`,
bearer: APPLE_JANE_ADMIN_ACCESS_TOKEN,
});
assertRestApiSuccessfulResponse(deleteResponse);
expect(deleteResponse.body.success).toBe(true);
const getResponse = await makeRestAPIRequest({
method: 'get',
path: `/metadata/viewFields/${viewField.id}`,
bearer: APPLE_JANE_ADMIN_ACCESS_TOKEN,
});
assertRestApiErrorNotFoundResponse(getResponse);
});
it('should return 404 error when deleting non-existent view field', async () => {
const response = await makeRestAPIRequest({
method: 'delete',
path: `/metadata/viewFields/20202020-f891-4d2a-8b23-c1e4d7f6a9b2`,
bearer: APPLE_JANE_ADMIN_ACCESS_TOKEN,
});
assertRestApiErrorNotFoundResponse(response);
});
});
});

View File

@@ -0,0 +1,516 @@
import { createOneFieldMetadata } from 'test/integration/metadata/suites/field-metadata/utils/create-one-field-metadata.util';
import { createOneObjectMetadata } from 'test/integration/metadata/suites/object-metadata/utils/create-one-object-metadata.util';
import { deleteOneObjectMetadata } from 'test/integration/metadata/suites/object-metadata/utils/delete-one-object-metadata.util';
import { updateOneObjectMetadata } from 'test/integration/metadata/suites/object-metadata/utils/update-one-object-metadata.util';
import { makeRestAPIRequest } from 'test/integration/rest/utils/make-rest-api-request.util';
import {
assertMetadataRestListResponse,
assertRestApiErrorNotFoundResponse,
assertRestApiErrorResponse,
assertRestApiSuccessfulResponse,
} from 'test/integration/rest/utils/rest-test-assertions.util';
import {
createTestViewFilterGroupWithRestApi,
createTestViewWithRestApi,
deleteTestViewFilterGroupWithRestApi,
} from 'test/integration/rest/utils/view-rest-api.util';
import { assertViewFilterGroupStructure } from 'test/integration/utils/view-test.util';
import { jestExpectToBeDefined } from 'test/utils/jest-expect-to-be-defined.util.test';
import {
FieldMetadataType,
ViewFilterGroupLogicalOperator,
} from 'twenty-shared/types';
import { type ViewFilterGroupDTO } from 'src/engine/metadata-modules/view-filter-group/dtos/view-filter-group.dto';
import {
generateViewFilterGroupExceptionMessage,
ViewFilterGroupExceptionMessageKey,
} from 'src/engine/metadata-modules/view-filter-group/exceptions/view-filter-group.exception';
describe('View Filter Group REST API', () => {
let testObjectMetadataId: string;
let testViewId: string;
let testViewFilterGroupId: string | undefined;
beforeAll(async () => {
const {
data: {
createOneObject: { id: objectMetadataId },
},
} = await createOneObjectMetadata({
input: {
nameSingular: 'testViewFilterGroupObject',
namePlural: 'testViewFilterGroupObjects',
labelSingular: 'Test View Filter Group Object',
labelPlural: 'Test View Filter Group Objects',
icon: 'IconFilterGroup',
},
});
testObjectMetadataId = objectMetadataId;
const createFieldInput = {
name: 'testField',
label: 'Test Field',
type: FieldMetadataType.TEXT,
objectMetadataId: testObjectMetadataId,
isLabelSyncedWithName: true,
};
await createOneFieldMetadata({
input: createFieldInput,
gqlFields: `
id
name
label
isLabelSyncedWithName
`,
});
const testView = await createTestViewWithRestApi({
name: 'Test View for Filter Group Integration',
objectMetadataId: testObjectMetadataId,
});
testViewId = testView.id;
});
afterAll(async () => {
await updateOneObjectMetadata({
expectToFail: false,
input: {
idToUpdate: testObjectMetadataId,
updatePayload: {
isActive: false,
},
},
});
await deleteOneObjectMetadata({
input: { idToDelete: testObjectMetadataId },
});
});
afterEach(async () => {
if (!testViewFilterGroupId) return;
await deleteTestViewFilterGroupWithRestApi(testViewFilterGroupId);
testViewFilterGroupId = undefined;
});
describe('GET /metadata/viewFilterGroups', () => {
it('should return empty array when no view filter groups exist', async () => {
const response = await makeRestAPIRequest({
method: 'get',
path: `/metadata/viewFilterGroups?viewId=${testViewId}`,
bearer: APPLE_JANE_ADMIN_ACCESS_TOKEN,
});
expect(
assertMetadataRestListResponse<ViewFilterGroupDTO>(response),
).toEqual([]);
expect(response.body.totalCount).toBe(0);
});
it('should return all view filter groups for workspace when no viewId provided', async () => {
const response = await makeRestAPIRequest({
method: 'get',
path: '/metadata/viewFilterGroups',
bearer: APPLE_JANE_ADMIN_ACCESS_TOKEN,
});
assertMetadataRestListResponse<ViewFilterGroupDTO>(response);
});
it('should return view filter groups for a specific view after creating one', async () => {
const viewFilterGroup = await createTestViewFilterGroupWithRestApi({
viewId: testViewId,
logicalOperator: ViewFilterGroupLogicalOperator.AND,
});
testViewFilterGroupId = viewFilterGroup.id;
const response = await makeRestAPIRequest({
method: 'get',
path: `/metadata/viewFilterGroups?viewId=${testViewId}`,
bearer: APPLE_JANE_ADMIN_ACCESS_TOKEN,
});
const viewFilterGroups =
assertMetadataRestListResponse<ViewFilterGroupDTO>(response);
const returnedViewFilterGroup = viewFilterGroups.find(
(el: ViewFilterGroupDTO) => el.id === viewFilterGroup.id,
);
jestExpectToBeDefined(returnedViewFilterGroup);
assertViewFilterGroupStructure(returnedViewFilterGroup, {
id: viewFilterGroup.id,
viewId: testViewId,
logicalOperator: ViewFilterGroupLogicalOperator.AND,
});
testViewFilterGroupId = viewFilterGroup.id;
});
it('should return nested filter groups with parent relationships', async () => {
const parentData = {
viewId: testViewId,
logicalOperator: 'AND',
objectMetadataId: testObjectMetadataId,
};
const parentResponse = await makeRestAPIRequest({
method: 'post',
path: '/metadata/viewFilterGroups',
body: parentData,
bearer: APPLE_JANE_ADMIN_ACCESS_TOKEN,
});
const parentId = parentResponse.body.id;
const childData = {
viewId: testViewId,
parentViewFilterGroupId: parentId,
logicalOperator: 'OR',
objectMetadataId: testObjectMetadataId,
};
const childResponse = await makeRestAPIRequest({
method: 'post',
path: '/metadata/viewFilterGroups',
body: childData,
bearer: APPLE_JANE_ADMIN_ACCESS_TOKEN,
});
const childId = childResponse.body.id;
const response = await makeRestAPIRequest({
method: 'get',
path: `/metadata/viewFilterGroups?viewId=${testViewId}`,
bearer: APPLE_JANE_ADMIN_ACCESS_TOKEN,
});
const viewFilterGroups =
assertMetadataRestListResponse<ViewFilterGroupDTO>(response);
expect(viewFilterGroups).toHaveLength(2);
const parentGroup = viewFilterGroups.find(
(group) => group.parentViewFilterGroupId === null,
);
const childGroup = viewFilterGroups.find(
(group) => group.parentViewFilterGroupId !== null,
);
jestExpectToBeDefined(parentGroup);
expect(parentGroup.logicalOperator).toBe('AND');
expect(parentGroup.parentViewFilterGroupId).toBeNull();
jestExpectToBeDefined(childGroup);
expect(childGroup.parentViewFilterGroupId).toBe(parentId);
expect(childGroup.logicalOperator).toBe('OR');
const deleteChildResponse = await makeRestAPIRequest({
method: 'delete',
path: `/metadata/viewFilterGroups/${childId}`,
bearer: APPLE_JANE_ADMIN_ACCESS_TOKEN,
});
assertRestApiSuccessfulResponse(deleteChildResponse);
const deleteParentResponse = await makeRestAPIRequest({
method: 'delete',
path: `/metadata/viewFilterGroups/${parentId}`,
bearer: APPLE_JANE_ADMIN_ACCESS_TOKEN,
});
assertRestApiSuccessfulResponse(deleteParentResponse);
});
});
describe('POST /metadata/viewFilterGroups', () => {
it('should create a new filter group with AND operator', async () => {
const viewFilterGroup = await createTestViewFilterGroupWithRestApi({
viewId: testViewId,
logicalOperator: ViewFilterGroupLogicalOperator.AND,
});
testViewFilterGroupId = viewFilterGroup.id;
assertViewFilterGroupStructure(viewFilterGroup, {
viewId: testViewId,
logicalOperator: ViewFilterGroupLogicalOperator.AND,
});
expect(viewFilterGroup.parentViewFilterGroupId).toBeNull();
});
it('should create a filter group with OR operator', async () => {
const orGroup = await createTestViewFilterGroupWithRestApi({
viewId: testViewId,
logicalOperator: ViewFilterGroupLogicalOperator.OR,
});
testViewFilterGroupId = orGroup.id;
assertViewFilterGroupStructure(orGroup, {
viewId: testViewId,
logicalOperator: ViewFilterGroupLogicalOperator.OR,
});
expect(orGroup.parentViewFilterGroupId).toBeNull();
});
it('should create a filter group with NOT operator', async () => {
const viewFilterGroupData = {
viewId: testViewId,
logicalOperator: 'NOT',
};
const notGroupResponse = await makeRestAPIRequest({
method: 'post',
path: '/metadata/viewFilterGroups',
body: viewFilterGroupData,
bearer: APPLE_JANE_ADMIN_ACCESS_TOKEN,
});
assertRestApiSuccessfulResponse(notGroupResponse, 201);
const notGroupId = notGroupResponse.body.id;
await makeRestAPIRequest({
method: 'delete',
path: `/metadata/viewFilterGroups/${notGroupId}`,
bearer: APPLE_JANE_ADMIN_ACCESS_TOKEN,
});
});
it('should create a nested filter group with parent relationship', async () => {
const parentData = {
viewId: testViewId,
logicalOperator: 'AND',
objectMetadataId: testObjectMetadataId,
};
const parentResponse = await makeRestAPIRequest({
method: 'post',
path: '/metadata/viewFilterGroups',
body: parentData,
bearer: APPLE_JANE_ADMIN_ACCESS_TOKEN,
});
const parentId = parentResponse.body.id;
const childData = {
viewId: testViewId,
parentViewFilterGroupId: parentId,
logicalOperator: 'OR',
objectMetadataId: testObjectMetadataId,
};
const childGroupResponse = await makeRestAPIRequest({
method: 'post',
path: '/metadata/viewFilterGroups',
body: childData,
bearer: APPLE_JANE_ADMIN_ACCESS_TOKEN,
});
assertRestApiSuccessfulResponse(childGroupResponse, 201);
});
});
describe('GET /metadata/viewFilterGroups/:id', () => {
it('should return a view filter group by id', async () => {
const viewFilterGroupData = {
viewId: testViewId,
logicalOperator: 'NOT',
objectMetadataId: testObjectMetadataId,
};
const createResponse = await makeRestAPIRequest({
method: 'post',
path: '/metadata/viewFilterGroups',
body: viewFilterGroupData,
bearer: APPLE_JANE_ADMIN_ACCESS_TOKEN,
});
const viewFilterGroupId = createResponse.body.id;
const response = await makeRestAPIRequest({
method: 'get',
path: `/metadata/viewFilterGroups/${viewFilterGroupId}`,
bearer: APPLE_JANE_ADMIN_ACCESS_TOKEN,
});
assertRestApiSuccessfulResponse(response);
expect(response.body).toBeDefined();
expect(response.body.id).toBe(viewFilterGroupId);
expect(response.body.viewId).toBe(testViewId);
expect(response.body.logicalOperator).toBe('NOT');
});
it('should return empty object for non-existent view filter group', async () => {
const response = await makeRestAPIRequest({
method: 'get',
path: `/metadata/viewFilterGroups/20202020-e214-44fa-a39a-d81447b2c44f`,
bearer: APPLE_JANE_ADMIN_ACCESS_TOKEN,
});
assertRestApiErrorNotFoundResponse(response);
});
});
describe('PATCH /metadata/viewFilterGroups/:id', () => {
it('should update an existing filter group', async () => {
const viewFilterGroupData = {
viewId: testViewId,
logicalOperator: 'AND',
objectMetadataId: testObjectMetadataId,
};
const createResponse = await makeRestAPIRequest({
method: 'post',
path: '/metadata/viewFilterGroups',
body: viewFilterGroupData,
bearer: APPLE_JANE_ADMIN_ACCESS_TOKEN,
});
const viewFilterGroupId = createResponse.body.id;
const updateData = {
logicalOperator: 'OR',
objectMetadataId: testObjectMetadataId,
};
const response = await makeRestAPIRequest({
method: 'patch',
path: `/metadata/viewFilterGroups/${viewFilterGroupId}`,
body: updateData,
bearer: APPLE_JANE_ADMIN_ACCESS_TOKEN,
});
assertRestApiSuccessfulResponse(response);
expect(response.body).toBeDefined();
expect(response.body.id).toBe(viewFilterGroupId);
expect(response.body.logicalOperator).toBe('OR');
expect(response.body.viewId).toBe(testViewId);
});
it('should update parent relationship of filter group', async () => {
const parentData = {
viewId: testViewId,
logicalOperator: 'AND',
objectMetadataId: testObjectMetadataId,
};
const parentResponse = await makeRestAPIRequest({
method: 'post',
path: '/metadata/viewFilterGroups',
body: parentData,
bearer: APPLE_JANE_ADMIN_ACCESS_TOKEN,
});
const parentId = parentResponse.body.id;
const childData = {
viewId: testViewId,
logicalOperator: 'OR',
objectMetadataId: testObjectMetadataId,
};
const childResponse = await makeRestAPIRequest({
method: 'post',
path: '/metadata/viewFilterGroups',
body: childData,
bearer: APPLE_JANE_ADMIN_ACCESS_TOKEN,
});
const childId = childResponse.body.id;
const updateData = {
parentViewFilterGroupId: parentId,
};
const response = await makeRestAPIRequest({
method: 'patch',
path: `/metadata/viewFilterGroups/${childId}`,
body: updateData,
bearer: APPLE_JANE_ADMIN_ACCESS_TOKEN,
});
assertRestApiSuccessfulResponse(response);
expect(response.body).toBeDefined();
expect(response.body.id).toBe(childId);
expect(response.body.parentViewFilterGroupId).toBe(parentId);
expect(response.body.logicalOperator).toBe(
ViewFilterGroupLogicalOperator.OR,
);
});
it('should return 404 error when updating non-existent filter group', async () => {
const updateData = {
logicalOperator: 'AND',
};
const response = await makeRestAPIRequest({
method: 'patch',
path: `/metadata/viewFilterGroups/20202020-e214-44fa-a39a-d81447b2c44f`,
body: updateData,
bearer: APPLE_JANE_ADMIN_ACCESS_TOKEN,
});
assertRestApiErrorResponse(
response,
404,
generateViewFilterGroupExceptionMessage(
ViewFilterGroupExceptionMessageKey.VIEW_FILTER_GROUP_NOT_FOUND,
'20202020-e214-44fa-a39a-d81447b2c44f',
),
);
});
});
describe('DELETE /metadata/viewFilterGroups/:id', () => {
it('should delete an existing filter group', async () => {
const viewFilterGroupData = {
viewId: testViewId,
logicalOperator: 'AND',
objectMetadataId: testObjectMetadataId,
};
const createResponse = await makeRestAPIRequest({
method: 'post',
path: '/metadata/viewFilterGroups',
body: viewFilterGroupData,
bearer: APPLE_JANE_ADMIN_ACCESS_TOKEN,
});
const viewFilterGroupId = createResponse.body.id;
const deleteResponse = await makeRestAPIRequest({
method: 'delete',
path: `/metadata/viewFilterGroups/${viewFilterGroupId}`,
bearer: APPLE_JANE_ADMIN_ACCESS_TOKEN,
});
assertRestApiSuccessfulResponse(deleteResponse);
expect(deleteResponse.body).toBeDefined();
expect(deleteResponse.body.success).toBe(true);
const response = await makeRestAPIRequest({
method: 'get',
path: `/metadata/viewFilterGroups/${viewFilterGroupId}`,
bearer: APPLE_JANE_ADMIN_ACCESS_TOKEN,
});
assertRestApiErrorNotFoundResponse(response);
});
it('should return 404 error when deleting non-existent filter group', async () => {
const response = await makeRestAPIRequest({
method: 'delete',
path: `/metadata/viewFilterGroups/20202020-e214-44fa-a39a-d81447b2c44f`,
bearer: APPLE_JANE_ADMIN_ACCESS_TOKEN,
});
assertRestApiErrorNotFoundResponse(response);
});
});
});

View File

@@ -0,0 +1,351 @@
import { createOneFieldMetadata } from 'test/integration/metadata/suites/field-metadata/utils/create-one-field-metadata.util';
import { createOneObjectMetadata } from 'test/integration/metadata/suites/object-metadata/utils/create-one-object-metadata.util';
import { deleteOneObjectMetadata } from 'test/integration/metadata/suites/object-metadata/utils/delete-one-object-metadata.util';
import { updateOneObjectMetadata } from 'test/integration/metadata/suites/object-metadata/utils/update-one-object-metadata.util';
import { destroyOneViewFilter } from 'test/integration/metadata/suites/view-filter/utils/destroy-one-view-filter.util';
import { makeRestAPIRequest } from 'test/integration/rest/utils/make-rest-api-request.util';
import {
assertMetadataRestListResponse,
assertRestApiErrorNotFoundResponse,
assertRestApiSuccessfulResponse,
} from 'test/integration/rest/utils/rest-test-assertions.util';
import {
createTestViewFilterWithRestApi,
createTestViewWithRestApi,
} from 'test/integration/rest/utils/view-rest-api.util';
import { assertViewFilterStructure } from 'test/integration/utils/view-test.util';
import { jestExpectToBeDefined } from 'test/utils/jest-expect-to-be-defined.util.test';
import { FieldMetadataType, ViewFilterOperand } from 'twenty-shared/types';
import { type ViewFilterDTO } from 'src/engine/metadata-modules/view-filter/dtos/view-filter.dto';
describe('View Filter REST API', () => {
let testObjectMetadataId: string;
let testFieldMetadataId: string;
let testViewId: string;
let testViewFilterId: string | undefined;
beforeAll(async () => {
const {
data: {
createOneObject: { id: objectMetadataId },
},
} = await createOneObjectMetadata({
input: {
nameSingular: 'testViewFilterObject',
namePlural: 'testViewFilterObjects',
labelSingular: 'Test View Filter Object',
labelPlural: 'Test View Filter Objects',
icon: 'IconFilter',
},
});
testObjectMetadataId = objectMetadataId;
const createFieldInput = {
name: 'testField',
label: 'Test Field',
type: FieldMetadataType.TEXT,
objectMetadataId: testObjectMetadataId,
isLabelSyncedWithName: true,
};
const {
data: {
createOneField: { id: fieldMetadataId },
},
} = await createOneFieldMetadata({
input: createFieldInput,
gqlFields: `
id
name
label
isLabelSyncedWithName
`,
});
testFieldMetadataId = fieldMetadataId;
const testView = await createTestViewWithRestApi({
name: 'Test View for Filter Integration',
objectMetadataId: testObjectMetadataId,
});
testViewId = testView.id;
});
afterAll(async () => {
await updateOneObjectMetadata({
expectToFail: false,
input: {
idToUpdate: testObjectMetadataId,
updatePayload: {
isActive: false,
},
},
});
await deleteOneObjectMetadata({
input: { idToDelete: testObjectMetadataId },
});
});
afterEach(async () => {
if (!testViewFilterId) return;
await destroyOneViewFilter({
input: {
id: testViewFilterId,
},
expectToFail: false,
});
testViewFilterId = undefined;
});
describe('GET /metadata/viewFilters', () => {
it('should return empty array when no view filters exist', async () => {
const response = await makeRestAPIRequest({
method: 'get',
path: `/metadata/viewFilters?viewId=${testViewId}`,
bearer: APPLE_JANE_ADMIN_ACCESS_TOKEN,
});
expect(assertMetadataRestListResponse<ViewFilterDTO>(response)).toEqual(
[],
);
expect(response.body.totalCount).toBe(0);
});
it('should return all view filters for workspace when no viewId provided', async () => {
const response = await makeRestAPIRequest({
method: 'get',
path: '/metadata/viewFilters',
bearer: APPLE_JANE_ADMIN_ACCESS_TOKEN,
});
assertMetadataRestListResponse<ViewFilterDTO>(response);
});
it('should return view filters for a specific view after creating one', async () => {
const viewFilter = await createTestViewFilterWithRestApi({
viewId: testViewId,
operand: ViewFilterOperand.CONTAINS,
value: 'test',
fieldMetadataId: testFieldMetadataId,
});
testViewFilterId = viewFilter.id;
const response = await makeRestAPIRequest({
method: 'get',
path: `/metadata/viewFilters?viewId=${testViewId}`,
bearer: APPLE_JANE_ADMIN_ACCESS_TOKEN,
});
const viewFilters =
assertMetadataRestListResponse<ViewFilterDTO>(response);
const returnedViewFilter = viewFilters.find(
(el: ViewFilterDTO) => el.id === viewFilter.id,
);
jestExpectToBeDefined(returnedViewFilter);
assertViewFilterStructure(returnedViewFilter, {
id: viewFilter.id,
fieldMetadataId: testFieldMetadataId,
viewId: testViewId,
operand: ViewFilterOperand.CONTAINS,
value: 'test',
});
});
});
describe('POST /metadata/viewFilters', () => {
it('should create a new view filter with string value', async () => {
const viewFilter = await createTestViewFilterWithRestApi({
viewId: testViewId,
operand: ViewFilterOperand.CONTAINS,
value: 'test value',
fieldMetadataId: testFieldMetadataId,
});
testViewFilterId = viewFilter.id;
assertViewFilterStructure(viewFilter, {
fieldMetadataId: testFieldMetadataId,
viewId: testViewId,
operand: ViewFilterOperand.CONTAINS,
value: 'test value',
});
testViewFilterId = viewFilter.id;
});
it('should create a view filter with numeric value', async () => {
const numericFilter = await createTestViewFilterWithRestApi({
viewId: testViewId,
operand: ViewFilterOperand.CONTAINS,
value: '100',
fieldMetadataId: testFieldMetadataId,
});
testViewFilterId = numericFilter.id;
assertViewFilterStructure(numericFilter, {
fieldMetadataId: testFieldMetadataId,
viewId: testViewId,
operand: ViewFilterOperand.CONTAINS,
value: '100',
});
});
it('should create a view filter with boolean value', async () => {
const booleanFilter = await createTestViewFilterWithRestApi({
viewId: testViewId,
operand: ViewFilterOperand.CONTAINS,
value: 'true',
fieldMetadataId: testFieldMetadataId,
});
testViewFilterId = booleanFilter.id;
assertViewFilterStructure(booleanFilter, {
fieldMetadataId: testFieldMetadataId,
viewId: testViewId,
operand: ViewFilterOperand.CONTAINS,
value: 'true',
});
});
});
describe('GET /metadata/viewFilters/:id', () => {
it('should return a view filter by id', async () => {
const viewFilter = await createTestViewFilterWithRestApi({
viewId: testViewId,
operand: ViewFilterOperand.CONTAINS,
value: 'test',
fieldMetadataId: testFieldMetadataId,
});
testViewFilterId = viewFilter.id;
const response = await makeRestAPIRequest({
method: 'get',
path: `/metadata/viewFilters/${viewFilter.id}`,
bearer: APPLE_JANE_ADMIN_ACCESS_TOKEN,
});
assertRestApiSuccessfulResponse(response);
assertViewFilterStructure(response.body, {
id: viewFilter.id,
fieldMetadataId: testFieldMetadataId,
viewId: testViewId,
operand: ViewFilterOperand.CONTAINS,
value: 'test',
});
testViewFilterId = viewFilter.id;
});
it('should return empty object for non-existent view filter', async () => {
const response = await makeRestAPIRequest({
method: 'get',
path: `/metadata/viewFilters/20202020-5262-419d-ab77-575bfaf3db28`,
bearer: APPLE_JANE_ADMIN_ACCESS_TOKEN,
});
assertRestApiErrorNotFoundResponse(response);
});
});
describe('PATCH /metadata/viewFilters/:id', () => {
it('should update an existing view filter', async () => {
const viewFilter = await createTestViewFilterWithRestApi({
viewId: testViewId,
operand: ViewFilterOperand.CONTAINS,
value: 'original',
fieldMetadataId: testFieldMetadataId,
});
testViewFilterId = viewFilter.id;
const updateData = {
operand: ViewFilterOperand.DOES_NOT_CONTAIN,
value: 'updated',
};
const response = await makeRestAPIRequest({
method: 'patch',
path: `/metadata/viewFilters/${viewFilter.id}`,
body: updateData,
bearer: APPLE_JANE_ADMIN_ACCESS_TOKEN,
});
assertRestApiSuccessfulResponse(response);
assertViewFilterStructure(response.body, {
id: viewFilter.id,
operand: ViewFilterOperand.DOES_NOT_CONTAIN,
value: 'updated',
fieldMetadataId: testFieldMetadataId,
viewId: testViewId,
});
testViewFilterId = viewFilter.id;
});
it('should return 404 error when updating non-existent view filter', async () => {
const updateData = {
operand: ViewFilterOperand.DOES_NOT_CONTAIN,
value: 'updated',
};
const response = await makeRestAPIRequest({
method: 'patch',
path: `/metadata/viewFilters/20202020-d8db-4dfb-b654-01b872851b37`,
body: updateData,
bearer: APPLE_JANE_ADMIN_ACCESS_TOKEN,
});
assertRestApiErrorNotFoundResponse(response);
});
});
describe('DELETE /metadata/viewFilters/:id', () => {
it('should delete an existing view filter', async () => {
const viewFilter = await createTestViewFilterWithRestApi({
viewId: testViewId,
operand: ViewFilterOperand.CONTAINS,
value: 'to delete',
fieldMetadataId: testFieldMetadataId,
});
testViewFilterId = viewFilter.id;
const deleteResponse = await makeRestAPIRequest({
method: 'delete',
path: `/metadata/viewFilters/${viewFilter.id}`,
bearer: APPLE_JANE_ADMIN_ACCESS_TOKEN,
});
assertRestApiSuccessfulResponse(deleteResponse);
expect(deleteResponse.body.success).toBe(true);
const getResponse = await makeRestAPIRequest({
method: 'get',
path: `/metadata/viewFilters/${viewFilter.id}`,
bearer: APPLE_JANE_ADMIN_ACCESS_TOKEN,
});
assertRestApiErrorNotFoundResponse(getResponse);
});
it('should return 404 error when deleting non-existent view filter', async () => {
const response = await makeRestAPIRequest({
method: 'delete',
path: `/metadata/viewFilters/20202020-b8a3-4885-ae28-b89c2a4942d8`,
bearer: APPLE_JANE_ADMIN_ACCESS_TOKEN,
});
assertRestApiErrorNotFoundResponse(response);
});
});
});

View File

@@ -0,0 +1,438 @@
import { createOneFieldMetadata } from 'test/integration/metadata/suites/field-metadata/utils/create-one-field-metadata.util';
import { createOneObjectMetadata } from 'test/integration/metadata/suites/object-metadata/utils/create-one-object-metadata.util';
import { deleteOneObjectMetadata } from 'test/integration/metadata/suites/object-metadata/utils/delete-one-object-metadata.util';
import { updateOneObjectMetadata } from 'test/integration/metadata/suites/object-metadata/utils/update-one-object-metadata.util';
import { destroyOneViewGroup } from 'test/integration/metadata/suites/view-group/utils/destroy-one-view-group.util';
import { makeRestAPIRequest } from 'test/integration/rest/utils/make-rest-api-request.util';
import {
assertMetadataRestListResponse,
assertRestApiErrorNotFoundResponse,
assertRestApiSuccessfulResponse,
} from 'test/integration/rest/utils/rest-test-assertions.util';
import {
createTestViewGroupWithRestApi,
createTestViewWithRestApi,
} from 'test/integration/rest/utils/view-rest-api.util';
import { assertViewGroupStructure } from 'test/integration/utils/view-test.util';
import { extractRecordIdsAndDatesAsExpectAny } from 'test/utils/extract-record-ids-and-dates-as-expect-any';
import { jestExpectToBeDefined } from 'test/utils/jest-expect-to-be-defined.util.test';
import { FieldMetadataType } from 'twenty-shared/types';
import { type ViewGroupDTO } from 'src/engine/metadata-modules/view-group/dtos/view-group.dto';
describe('View Group REST API', () => {
let testObjectMetadataId: string;
let testFieldMetadataId: string;
let testViewId: string;
let testViewGroupId: string | undefined;
beforeAll(async () => {
const {
data: {
createOneObject: { id: objectMetadataId },
},
} = await createOneObjectMetadata({
expectToFail: false,
input: {
nameSingular: 'testViewGroupObject',
namePlural: 'testViewGroupObjects',
labelSingular: 'Test View Group Object',
labelPlural: 'Test View Group Objects',
icon: 'IconGroup',
},
});
testObjectMetadataId = objectMetadataId;
const createFieldInput = {
name: 'testField',
label: 'Test Field',
type: FieldMetadataType.SELECT,
objectMetadataId: testObjectMetadataId,
isLabelSyncedWithName: true,
options: [
{ label: 'Option 1', value: 'OPTION_1', color: 'blue', position: 0 },
{ label: 'Option 2', value: 'OPTION_2', color: 'red', position: 1 },
{ label: 'Option 3', value: 'OPTION_3', color: 'green', position: 2 },
],
};
const {
data: {
createOneField: { id: fieldMetadataId },
},
} = await createOneFieldMetadata({
expectToFail: false,
input: createFieldInput,
gqlFields: `
id
name
label
isLabelSyncedWithName
`,
});
testFieldMetadataId = fieldMetadataId;
const testView = await createTestViewWithRestApi({
name: 'Test View for Group Integration',
objectMetadataId: testObjectMetadataId,
mainGroupByFieldMetadataId: testFieldMetadataId,
});
testViewId = testView.id;
});
afterAll(async () => {
await updateOneObjectMetadata({
expectToFail: false,
input: {
idToUpdate: testObjectMetadataId,
updatePayload: {
isActive: false,
},
},
});
await deleteOneObjectMetadata({
expectToFail: false,
input: { idToDelete: testObjectMetadataId },
});
});
afterEach(async () => {
if (!testViewGroupId) return;
await destroyOneViewGroup({
input: {
id: testViewGroupId,
},
expectToFail: false,
});
testViewGroupId = undefined;
});
describe('GET /metadata/viewGroups', () => {
it('should return all view groups for workspace when no viewId provided', async () => {
const response = await makeRestAPIRequest({
method: 'get',
path: '/metadata/viewGroups',
bearer: APPLE_JANE_ADMIN_ACCESS_TOKEN,
});
assertMetadataRestListResponse<ViewGroupDTO>(response);
});
it('should return view groups for a specific view after creating one', async () => {
const response = await makeRestAPIRequest({
method: 'get',
path: `/metadata/viewGroups?viewId=${testViewId}`,
bearer: APPLE_JANE_ADMIN_ACCESS_TOKEN,
});
const returnedViewGroups =
assertMetadataRestListResponse<ViewGroupDTO>(response);
expect(returnedViewGroups).toHaveLength(4);
// For a nullable field with three options, we expect groups for OPTION_1, OPTION_2, OPTION_3, and '' (empty string)
const expectedFieldValues = ['OPTION_1', 'OPTION_2', 'OPTION_3', ''];
expectedFieldValues.forEach((expectedFieldValue) => {
const group = returnedViewGroups.find(
(group) => group.fieldValue === expectedFieldValue,
);
jestExpectToBeDefined(group);
expect(group.isVisible).toBe(true);
expect(group.viewId).toBe(testViewId);
});
});
it('paginates the position-ordered result in both directions', async () => {
const firstResponse = await makeRestAPIRequest({
method: 'get',
path: `/metadata/viewGroups?viewId=${testViewId}&limit=2`,
bearer: APPLE_JANE_ADMIN_ACCESS_TOKEN,
});
const firstPage =
assertMetadataRestListResponse<ViewGroupDTO>(firstResponse);
expect(firstPage).toHaveLength(2);
expect(firstResponse.body.totalCount).toBe(4);
expect(firstResponse.body.pageInfo).toMatchObject({
hasNextPage: true,
hasPreviousPage: false,
});
const secondResponse = await makeRestAPIRequest({
method: 'get',
path: `/metadata/viewGroups?viewId=${testViewId}&limit=2&starting_after=${firstResponse.body.pageInfo.endCursor}`,
bearer: APPLE_JANE_ADMIN_ACCESS_TOKEN,
});
const secondPage =
assertMetadataRestListResponse<ViewGroupDTO>(secondResponse);
expect(secondPage).toHaveLength(2);
expect(secondResponse.body.pageInfo).toMatchObject({
hasNextPage: false,
hasPreviousPage: true,
});
const previousResponse = await makeRestAPIRequest({
method: 'get',
path: `/metadata/viewGroups?viewId=${testViewId}&limit=2&ending_before=${secondResponse.body.pageInfo.startCursor}`,
bearer: APPLE_JANE_ADMIN_ACCESS_TOKEN,
});
const previousPage =
assertMetadataRestListResponse<ViewGroupDTO>(previousResponse);
expect(previousPage.map(({ id }) => id)).toEqual(
firstPage.map(({ id }) => id),
);
});
});
describe('GET /metadata/viewGroups/:id', () => {
it('should return a specific view group by id', async () => {
const viewGroupsFromViewReponse = await makeRestAPIRequest({
method: 'get',
path: `/metadata/viewGroups?viewId=${testViewId}`,
bearer: APPLE_JANE_ADMIN_ACCESS_TOKEN,
});
const viewGroup = assertMetadataRestListResponse<ViewGroupDTO>(
viewGroupsFromViewReponse,
).find((group) => group.fieldValue === 'OPTION_1');
jestExpectToBeDefined(viewGroup);
const response = await makeRestAPIRequest({
method: 'get',
path: `/metadata/viewGroups/${viewGroup.id}`,
bearer: APPLE_JANE_ADMIN_ACCESS_TOKEN,
});
assertRestApiSuccessfulResponse(response);
assertViewGroupStructure(response.body, {
id: viewGroup.id,
viewId: testViewId,
fieldValue: 'OPTION_1',
isVisible: true,
});
testViewGroupId = viewGroup.id;
});
});
describe('POST /metadata/viewGroups', () => {
it('should create a new view group', async () => {
const viewGroupData = {
viewId: testViewId,
fieldValue: 'new-group-value',
isVisible: true,
position: 5,
};
const response = await makeRestAPIRequest({
method: 'post',
path: '/metadata/viewGroups',
body: viewGroupData,
bearer: APPLE_JANE_ADMIN_ACCESS_TOKEN,
});
assertRestApiSuccessfulResponse(response, 201);
assertViewGroupStructure(response.body, {
viewId: testViewId,
fieldValue: 'new-group-value',
isVisible: true,
position: 5,
});
testViewGroupId = response.body.id;
});
it('should create view group with minimal required fields', async () => {
const viewGroupData = {
viewId: testViewId,
fieldMetadataId: testFieldMetadataId,
fieldValue: 'minimal-group',
};
const response = await makeRestAPIRequest({
method: 'post',
path: '/metadata/viewGroups',
body: viewGroupData,
bearer: APPLE_JANE_ADMIN_ACCESS_TOKEN,
});
assertRestApiSuccessfulResponse(response, 201);
assertViewGroupStructure(response.body, {
viewId: testViewId,
fieldValue: 'minimal-group',
isVisible: true,
position: 0,
});
testViewGroupId = response.body.id;
});
it('should fail to create view group with missing required fields', async () => {
const invalidData = {
viewId: testViewId,
};
const response = await makeRestAPIRequest({
method: 'post',
path: '/metadata/viewGroups',
body: invalidData,
bearer: APPLE_JANE_ADMIN_ACCESS_TOKEN,
});
expect(response.status).toBe(400);
const errorResponse = JSON.parse(response.text);
expect(errorResponse).toMatchSnapshot(
extractRecordIdsAndDatesAsExpectAny(errorResponse),
);
});
});
describe('PATCH /metadata/viewGroups/:id', () => {
it('should update an existing view group', async () => {
const viewGroup = await createTestViewGroupWithRestApi({
viewId: testViewId,
fieldMetadataId: testFieldMetadataId,
fieldValue: 'original-value',
isVisible: true,
position: 1,
});
testViewGroupId = viewGroup.id;
const updateData = {
fieldValue: 'updated-value',
isVisible: false,
position: 2,
};
const response = await makeRestAPIRequest({
method: 'patch',
path: `/metadata/viewGroups/${viewGroup.id}`,
body: updateData,
bearer: APPLE_JANE_ADMIN_ACCESS_TOKEN,
});
assertRestApiSuccessfulResponse(response);
assertViewGroupStructure(response.body, {
id: viewGroup.id,
viewId: testViewId,
fieldValue: 'updated-value',
isVisible: false,
position: 2,
});
});
it('should update only specific fields', async () => {
const viewGroup = await createTestViewGroupWithRestApi({
viewId: testViewId,
fieldMetadataId: testFieldMetadataId,
fieldValue: 'original-value',
isVisible: true,
position: 1,
});
testViewGroupId = viewGroup.id;
const updateData = {
fieldValue: 'partially-updated',
};
const response = await makeRestAPIRequest({
method: 'patch',
path: `/metadata/viewGroups/${viewGroup.id}`,
body: updateData,
bearer: APPLE_JANE_ADMIN_ACCESS_TOKEN,
});
assertRestApiSuccessfulResponse(response);
assertViewGroupStructure(response.body, {
id: viewGroup.id,
fieldValue: 'partially-updated',
isVisible: true,
position: 1,
});
});
it('should return 404 for non-existent view group', async () => {
const updateData = {
fieldValue: 'test-update',
};
const response = await makeRestAPIRequest({
method: 'patch',
path: `/metadata/viewGroups/20202020-9c8b-4a7e-9f2d-1a2b3c4d5e6f`,
body: updateData,
bearer: APPLE_JANE_ADMIN_ACCESS_TOKEN,
});
assertRestApiErrorNotFoundResponse(response);
});
});
describe('DELETE /metadata/viewGroups/:id', () => {
it('should delete an existing view group', async () => {
const viewGroup = await createTestViewGroupWithRestApi({
viewId: testViewId,
fieldMetadataId: testFieldMetadataId,
fieldValue: 'to-be-deleted',
});
testViewGroupId = viewGroup.id;
const deleteResponse = await makeRestAPIRequest({
method: 'delete',
path: `/metadata/viewGroups/${viewGroup.id}`,
bearer: APPLE_JANE_ADMIN_ACCESS_TOKEN,
});
assertRestApiSuccessfulResponse(deleteResponse);
expect(deleteResponse.body).toEqual({ success: true });
});
it('should return 404 for non-existent view group', async () => {
const response = await makeRestAPIRequest({
method: 'delete',
path: `/metadata/viewGroups/20202020-9c8b-4a7e-9f2d-1a2b3c4d5e6f`,
bearer: APPLE_JANE_ADMIN_ACCESS_TOKEN,
});
assertRestApiErrorNotFoundResponse(response);
});
it('should return success even when group is already deleted', async () => {
const viewGroup = await createTestViewGroupWithRestApi({
viewId: testViewId,
fieldMetadataId: testFieldMetadataId,
fieldValue: 'double-delete-test',
});
testViewGroupId = viewGroup.id;
const deleteResponse = await makeRestAPIRequest({
method: 'delete',
path: `/metadata/viewGroups/${viewGroup.id}`,
bearer: APPLE_JANE_ADMIN_ACCESS_TOKEN,
});
assertRestApiSuccessfulResponse(deleteResponse);
const deleteResponse2 = await makeRestAPIRequest({
method: 'delete',
path: `/metadata/viewGroups/${viewGroup.id}`,
bearer: APPLE_JANE_ADMIN_ACCESS_TOKEN,
});
assertRestApiSuccessfulResponse(deleteResponse2);
});
});
});

View File

@@ -0,0 +1,318 @@
import { createOneFieldMetadata } from 'test/integration/metadata/suites/field-metadata/utils/create-one-field-metadata.util';
import { createOneObjectMetadata } from 'test/integration/metadata/suites/object-metadata/utils/create-one-object-metadata.util';
import { deleteOneObjectMetadata } from 'test/integration/metadata/suites/object-metadata/utils/delete-one-object-metadata.util';
import { updateOneObjectMetadata } from 'test/integration/metadata/suites/object-metadata/utils/update-one-object-metadata.util';
import { makeRestAPIRequest } from 'test/integration/rest/utils/make-rest-api-request.util';
import {
assertMetadataRestListResponse,
assertRestApiErrorNotFoundResponse,
assertRestApiErrorResponse,
assertRestApiSuccessfulResponse,
} from 'test/integration/rest/utils/rest-test-assertions.util';
import {
createTestViewSortWithRestApi,
createTestViewWithRestApi,
deleteTestViewSortWithRestApi,
} from 'test/integration/rest/utils/view-rest-api.util';
import { assertViewSortStructure } from 'test/integration/utils/view-test.util';
import { jestExpectToBeDefined } from 'test/utils/jest-expect-to-be-defined.util.test';
import { FieldMetadataType, ViewSortDirection } from 'twenty-shared/types';
import { type ViewSortDTO } from 'src/engine/metadata-modules/view-sort/dtos/view-sort.dto';
import {
generateViewSortExceptionMessage,
ViewSortExceptionMessageKey,
} from 'src/engine/metadata-modules/view-sort/exceptions/view-sort.exception';
describe('View Sort REST API', () => {
let testObjectMetadataId: string;
let testFieldMetadataId: string;
let testViewId: string;
let testViewSortId: string | undefined;
beforeAll(async () => {
const {
data: {
createOneObject: { id: objectMetadataId },
},
} = await createOneObjectMetadata({
input: {
nameSingular: 'testViewSortObject',
namePlural: 'testViewSortObjects',
labelSingular: 'Test View Sort Object',
labelPlural: 'Test View Sort Objects',
icon: 'IconSort',
},
});
testObjectMetadataId = objectMetadataId;
const createFieldInput = {
name: 'testField',
label: 'Test Field',
type: FieldMetadataType.TEXT,
objectMetadataId: testObjectMetadataId,
isLabelSyncedWithName: true,
};
const {
data: {
createOneField: { id: fieldMetadataId },
},
} = await createOneFieldMetadata({
input: createFieldInput,
gqlFields: `
id
name
label
isLabelSyncedWithName
`,
});
testFieldMetadataId = fieldMetadataId;
const testView = await createTestViewWithRestApi({
name: 'Test View for Sort Integration',
objectMetadataId: testObjectMetadataId,
});
testViewId = testView.id;
});
afterAll(async () => {
await updateOneObjectMetadata({
expectToFail: false,
input: {
idToUpdate: testObjectMetadataId,
updatePayload: {
isActive: false,
},
},
});
await deleteOneObjectMetadata({
input: { idToDelete: testObjectMetadataId },
});
});
afterEach(async () => {
if (!testViewSortId) return;
await deleteTestViewSortWithRestApi(testViewSortId);
testViewSortId = undefined;
});
describe('GET /metadata/viewSorts', () => {
it('should return empty array when no view sorts exist', async () => {
const response = await makeRestAPIRequest({
method: 'get',
path: `/metadata/viewSorts?viewId=${testViewId}`,
bearer: APPLE_JANE_ADMIN_ACCESS_TOKEN,
});
expect(assertMetadataRestListResponse<ViewSortDTO>(response)).toEqual([]);
expect(response.body.totalCount).toBe(0);
});
it('should return all view sorts for workspace when no viewId provided', async () => {
const response = await makeRestAPIRequest({
method: 'get',
path: '/metadata/viewSorts',
bearer: APPLE_JANE_ADMIN_ACCESS_TOKEN,
});
assertMetadataRestListResponse<ViewSortDTO>(response);
});
it('should return view sorts for a specific view after creating one', async () => {
const viewSort = await createTestViewSortWithRestApi({
viewId: testViewId,
fieldMetadataId: testFieldMetadataId,
direction: ViewSortDirection.ASC,
});
testViewSortId = viewSort.id;
const response = await makeRestAPIRequest({
method: 'get',
path: `/metadata/viewSorts?viewId=${testViewId}`,
bearer: APPLE_JANE_ADMIN_ACCESS_TOKEN,
});
const viewSorts = assertMetadataRestListResponse<ViewSortDTO>(response);
const returnedViewSort = viewSorts.find(
(el: ViewSortDTO) => el.id === viewSort.id,
);
jestExpectToBeDefined(returnedViewSort);
assertViewSortStructure(returnedViewSort, {
id: viewSort.id,
fieldMetadataId: testFieldMetadataId,
viewId: testViewId,
direction: ViewSortDirection.ASC,
});
});
});
describe('POST /metadata/viewSorts', () => {
it('should create a new view sort with ASC direction', async () => {
const viewSort = await createTestViewSortWithRestApi({
viewId: testViewId,
fieldMetadataId: testFieldMetadataId,
direction: ViewSortDirection.ASC,
});
testViewSortId = viewSort.id;
assertViewSortStructure(viewSort, {
fieldMetadataId: testFieldMetadataId,
viewId: testViewId,
direction: ViewSortDirection.ASC,
});
});
it('should create a view sort with DESC direction', async () => {
const descSort = await createTestViewSortWithRestApi({
viewId: testViewId,
fieldMetadataId: testFieldMetadataId,
direction: ViewSortDirection.DESC,
});
testViewSortId = descSort.id;
assertViewSortStructure(descSort, {
fieldMetadataId: testFieldMetadataId,
viewId: testViewId,
direction: ViewSortDirection.DESC,
});
});
});
describe('GET /metadata/viewSorts/:id', () => {
it('should return a view sort by id', async () => {
const viewSort = await createTestViewSortWithRestApi({
viewId: testViewId,
fieldMetadataId: testFieldMetadataId,
direction: ViewSortDirection.ASC,
});
testViewSortId = viewSort.id;
const response = await makeRestAPIRequest({
method: 'get',
path: `/metadata/viewSorts/${viewSort.id}`,
bearer: APPLE_JANE_ADMIN_ACCESS_TOKEN,
});
assertRestApiSuccessfulResponse(response);
assertViewSortStructure(response.body, {
id: viewSort.id,
fieldMetadataId: testFieldMetadataId,
viewId: testViewId,
direction: ViewSortDirection.ASC,
});
});
it('should return empty object for non-existent view sort', async () => {
const response = await makeRestAPIRequest({
method: 'get',
path: `/metadata/viewSorts/20202020-a1b2-4c3d-8e9f-123456789abc`,
bearer: APPLE_JANE_ADMIN_ACCESS_TOKEN,
});
assertRestApiErrorNotFoundResponse(response);
});
});
describe('PATCH /metadata/viewSorts/:id', () => {
it('should update an existing view sort', async () => {
const viewSort = await createTestViewSortWithRestApi({
viewId: testViewId,
fieldMetadataId: testFieldMetadataId,
direction: ViewSortDirection.ASC,
});
testViewSortId = viewSort.id;
const updateData = {
direction: ViewSortDirection.DESC,
};
const response = await makeRestAPIRequest({
method: 'patch',
path: `/metadata/viewSorts/${viewSort.id}`,
body: updateData,
bearer: APPLE_JANE_ADMIN_ACCESS_TOKEN,
});
assertRestApiSuccessfulResponse(response);
assertViewSortStructure(response.body, {
id: viewSort.id,
direction: ViewSortDirection.DESC,
fieldMetadataId: testFieldMetadataId,
viewId: testViewId,
});
});
it('should return 404 error when updating non-existent view sort', async () => {
const updateData = {
direction: ViewSortDirection.DESC,
};
const response = await makeRestAPIRequest({
method: 'patch',
path: `/metadata/viewSorts/20202020-a1b2-4c3d-8e9f-123456789abc`,
body: updateData,
bearer: APPLE_JANE_ADMIN_ACCESS_TOKEN,
});
assertRestApiErrorNotFoundResponse(response);
});
});
describe('DELETE /metadata/viewSorts/:id', () => {
it('should delete an existing view sort', async () => {
const viewSort = await createTestViewSortWithRestApi({
viewId: testViewId,
fieldMetadataId: testFieldMetadataId,
direction: ViewSortDirection.ASC,
});
testViewSortId = viewSort.id;
const deleteResponse = await makeRestAPIRequest({
method: 'delete',
path: `/metadata/viewSorts/${viewSort.id}`,
bearer: APPLE_JANE_ADMIN_ACCESS_TOKEN,
});
assertRestApiSuccessfulResponse(deleteResponse);
expect(deleteResponse.body.success).toBe(true);
const getResponse = await makeRestAPIRequest({
method: 'get',
path: `/metadata/viewSorts/${viewSort.id}`,
bearer: APPLE_JANE_ADMIN_ACCESS_TOKEN,
});
assertRestApiErrorNotFoundResponse(getResponse);
});
it('should return 404 error when deleting non-existent view sort', async () => {
const response = await makeRestAPIRequest({
method: 'delete',
path: `/metadata/viewSorts/20202020-a1b2-4c3d-8e9f-123456789abc`,
bearer: APPLE_JANE_ADMIN_ACCESS_TOKEN,
});
assertRestApiErrorResponse(
response,
404,
generateViewSortExceptionMessage(
ViewSortExceptionMessageKey.VIEW_SORT_NOT_FOUND,
'20202020-a1b2-4c3d-8e9f-123456789abc',
),
);
});
});
});

View File

@@ -0,0 +1,301 @@
import { createOneSelectFieldMetadataForIntegrationTests } from 'test/integration/metadata/suites/field-metadata/utils/create-one-select-field-metadata-for-integration-tests.util';
import { createOneObjectMetadata } from 'test/integration/metadata/suites/object-metadata/utils/create-one-object-metadata.util';
import { deleteOneObjectMetadata } from 'test/integration/metadata/suites/object-metadata/utils/delete-one-object-metadata.util';
import { updateOneObjectMetadata } from 'test/integration/metadata/suites/object-metadata/utils/update-one-object-metadata.util';
import { destroyOneView } from 'test/integration/metadata/suites/view/utils/destroy-one-view.util';
import { makeRestAPIRequest } from 'test/integration/rest/utils/make-rest-api-request.util';
import {
assertMetadataRestListResponse,
assertRestApiErrorNotFoundResponse,
assertRestApiSuccessfulResponse,
} from 'test/integration/rest/utils/rest-test-assertions.util';
import { createTestViewWithRestApi } from 'test/integration/rest/utils/view-rest-api.util';
import { generateRecordName } from 'test/integration/utils/generate-record-name';
import { assertViewStructure } from 'test/integration/utils/view-test.util';
import { ViewOpenRecordIn, ViewType } from 'twenty-shared/types';
import { type ViewDTO } from 'src/engine/metadata-modules/view/dtos/view.dto';
describe('View REST API', () => {
let testObjectMetadataId: string;
let testSelectFieldMetadataId: string;
let testViewId: string | undefined;
beforeAll(async () => {
const {
data: {
createOneObject: { id: objectMetadataId },
},
} = await createOneObjectMetadata({
input: {
nameSingular: 'testViewObject',
namePlural: 'testViewObjects',
labelSingular: 'Test View Object',
labelPlural: 'Test View Objects',
icon: 'IconView',
},
});
testObjectMetadataId = objectMetadataId;
const { selectFieldMetadataId } =
await createOneSelectFieldMetadataForIntegrationTests({
input: {
objectMetadataId,
},
});
testSelectFieldMetadataId = selectFieldMetadataId;
});
afterAll(async () => {
await updateOneObjectMetadata({
expectToFail: false,
input: {
idToUpdate: testObjectMetadataId,
updatePayload: {
isActive: false,
},
},
});
await deleteOneObjectMetadata({
input: { idToDelete: testObjectMetadataId },
});
});
afterEach(async () => {
if (!testViewId) return;
await destroyOneView({
viewId: testViewId,
expectToFail: false,
});
testViewId = undefined;
});
describe('GET /metadata/views', () => {
it('should return all views for workspace', async () => {
const response = await makeRestAPIRequest({
method: 'get',
path: '/metadata/views',
bearer: APPLE_JANE_ADMIN_ACCESS_TOKEN,
});
assertMetadataRestListResponse<ViewDTO>(response);
});
it('should return views filtered by objectMetadataId', async () => {
const response = await makeRestAPIRequest({
method: 'get',
path: `/metadata/views?objectMetadataId=${testObjectMetadataId}`,
bearer: APPLE_JANE_ADMIN_ACCESS_TOKEN,
});
const views = assertMetadataRestListResponse<ViewDTO>(response);
if (views.length > 0) {
assertViewStructure(views[0]);
}
});
});
describe('POST /metadata/views', () => {
it('should create a new view', async () => {
const viewName = generateRecordName('Test View');
const view = await createTestViewWithRestApi({
name: viewName,
icon: 'IconTable',
type: ViewType.TABLE,
key: null,
position: 0,
isCompact: false,
openRecordIn: ViewOpenRecordIn.SIDE_PANEL,
objectMetadataId: testObjectMetadataId,
});
testViewId = view.id;
assertViewStructure(view, {
name: viewName,
objectMetadataId: testObjectMetadataId,
icon: 'IconTable',
type: ViewType.TABLE,
key: null,
position: 0,
isCompact: false,
openRecordIn: ViewOpenRecordIn.SIDE_PANEL,
});
});
it('should create a kanban view', async () => {
const viewName = generateRecordName('Test Kanban View');
const kanbanView = await createTestViewWithRestApi({
name: viewName,
icon: 'IconKanban',
type: ViewType.KANBAN,
key: null,
position: 1,
isCompact: true,
openRecordIn: ViewOpenRecordIn.SIDE_PANEL,
objectMetadataId: testObjectMetadataId,
mainGroupByFieldMetadataId: testSelectFieldMetadataId,
});
testViewId = kanbanView.id;
assertViewStructure(kanbanView, {
name: viewName,
type: ViewType.KANBAN,
isCompact: true,
openRecordIn: ViewOpenRecordIn.SIDE_PANEL,
objectMetadataId: testObjectMetadataId,
});
});
});
describe('GET /metadata/views/:id', () => {
it('should return a view by id', async () => {
const viewName = generateRecordName('Test View for Get');
const view = await createTestViewWithRestApi({
name: viewName,
icon: 'IconTable',
type: ViewType.TABLE,
key: null,
position: 0,
isCompact: false,
openRecordIn: ViewOpenRecordIn.SIDE_PANEL,
objectMetadataId: testObjectMetadataId,
});
testViewId = view.id;
const response = await makeRestAPIRequest({
method: 'get',
path: `/metadata/views/${view.id}`,
bearer: APPLE_JANE_ADMIN_ACCESS_TOKEN,
});
assertRestApiSuccessfulResponse(response);
assertViewStructure(response.body, {
id: view.id,
name: viewName,
objectMetadataId: testObjectMetadataId,
});
});
it('should return empty object for non-existent view', async () => {
const response = await makeRestAPIRequest({
method: 'get',
path: `/metadata/views/20202020-b7a4-4f8e-9c1d-2e3f4a5b6c7d`,
bearer: APPLE_JANE_ADMIN_ACCESS_TOKEN,
});
assertRestApiErrorNotFoundResponse(response);
});
});
describe('PATCH /metadata/views/:id', () => {
it('should update an existing view', async () => {
const viewName = generateRecordName('Test View for Update');
const view = await createTestViewWithRestApi({
name: viewName,
icon: 'IconTable',
type: ViewType.TABLE,
key: null,
position: 0,
isCompact: false,
openRecordIn: ViewOpenRecordIn.SIDE_PANEL,
objectMetadataId: testObjectMetadataId,
});
testViewId = view.id;
const updatedName = generateRecordName('Updated View');
const updateData = {
name: updatedName,
type: ViewType.KANBAN,
isCompact: true,
openRecordIn: ViewOpenRecordIn.SIDE_PANEL,
mainGroupByFieldMetadataId: testSelectFieldMetadataId,
};
const response = await makeRestAPIRequest({
method: 'patch',
path: `/metadata/views/${view.id}`,
body: updateData,
bearer: APPLE_JANE_ADMIN_ACCESS_TOKEN,
});
assertRestApiSuccessfulResponse(response);
assertViewStructure(response.body, {
id: view.id,
name: updatedName,
type: ViewType.KANBAN,
isCompact: true,
openRecordIn: ViewOpenRecordIn.SIDE_PANEL,
objectMetadataId: testObjectMetadataId,
});
});
it('should return 404 error when updating non-existent view', async () => {
const updateData = {
name: 'Updated View',
type: ViewType.KANBAN,
};
const response = await makeRestAPIRequest({
method: 'patch',
path: `/metadata/views/20202020-b7a4-4f8e-9c1d-2e3f4a5b6c7d`,
body: updateData,
bearer: APPLE_JANE_ADMIN_ACCESS_TOKEN,
});
assertRestApiErrorNotFoundResponse(response);
});
});
describe('DELETE /metadata/views/:id', () => {
it('should delete an existing view', async () => {
const viewName = generateRecordName('Test View for Delete');
const view = await createTestViewWithRestApi({
name: viewName,
icon: 'IconTable',
type: ViewType.TABLE,
key: null,
position: 0,
isCompact: false,
openRecordIn: ViewOpenRecordIn.SIDE_PANEL,
objectMetadataId: testObjectMetadataId,
});
testViewId = view.id;
const deleteResponse = await makeRestAPIRequest({
method: 'delete',
path: `/metadata/views/${view.id}`,
bearer: APPLE_JANE_ADMIN_ACCESS_TOKEN,
});
assertRestApiSuccessfulResponse(deleteResponse);
expect(deleteResponse.body.success).toBe(true);
const getResponse = await makeRestAPIRequest({
method: 'get',
path: `/metadata/views/${view.id}`,
bearer: APPLE_JANE_ADMIN_ACCESS_TOKEN,
});
assertRestApiErrorNotFoundResponse(getResponse);
});
it('should return 404 error when deleting non-existent view', async () => {
const response = await makeRestAPIRequest({
method: 'delete',
path: `/metadata/views/20202020-b7a4-4f8e-9c1d-2e3f4a5b6c7d`,
bearer: APPLE_JANE_ADMIN_ACCESS_TOKEN,
});
assertRestApiErrorNotFoundResponse(response);
});
});
});

View File

@@ -0,0 +1,30 @@
import request from 'supertest';
export type RestAPIRequestMethod = 'get' | 'post' | 'put' | 'patch' | 'delete';
interface RestAPIRequestParams {
method: RestAPIRequestMethod;
path: string;
bearer?: string;
body?: any;
}
export const makeRestAPIRequest = ({
method,
path,
bearer = API_KEY_ACCESS_TOKEN,
body = {},
}: RestAPIRequestParams) => {
const client = request(`http://localhost:${APP_PORT}`);
const req = client[method](`/rest${path}`).set(
'Authorization',
`Bearer ${bearer}`,
);
if (['post', 'patch', 'put'].includes(method)) {
req.set('Content-Type', 'application/json').send(JSON.stringify(body));
}
return req;
};

View File

@@ -0,0 +1,150 @@
import { createOneFieldMetadata } from 'test/integration/metadata/suites/field-metadata/utils/create-one-field-metadata.util';
import { deleteOneFieldMetadata } from 'test/integration/metadata/suites/field-metadata/utils/delete-one-field-metadata.util';
import { createOneObjectMetadata } from 'test/integration/metadata/suites/object-metadata/utils/create-one-object-metadata.util';
import { deleteOneObjectMetadata } from 'test/integration/metadata/suites/object-metadata/utils/delete-one-object-metadata.util';
import { updateOneObjectMetadata } from 'test/integration/metadata/suites/object-metadata/utils/update-one-object-metadata.util';
import { FieldMetadataType } from 'twenty-shared/types';
export const NON_EXISTENT_UUID = '00000000-0000-4000-8000-000000000000';
export const uniqueSuffix = (): string =>
Math.random().toString(36).slice(2, 10);
export type CreateTestObjectInput = {
nameSingular: string;
namePlural: string;
labelSingular: string;
labelPlural: string;
icon: string;
isLabelSyncedWithName: boolean;
};
export const buildUniqueObjectInput = (): CreateTestObjectInput => {
const suffix = uniqueSuffix();
return {
nameSingular: `restTest${suffix}`,
namePlural: `restTest${suffix}s`,
labelSingular: `Rest Test ${suffix}`,
labelPlural: `Rest Tests ${suffix}`,
icon: 'IconTestPipe',
isLabelSyncedWithName: false,
};
};
export const createTestObjectViaGraphql = async (
overrides?: Partial<CreateTestObjectInput>,
): Promise<{ id: string; input: CreateTestObjectInput }> => {
const input = { ...buildUniqueObjectInput(), ...overrides };
const { data, errors } = await createOneObjectMetadata({ input });
if (!data?.createOneObject?.id) {
throw new Error(
`Failed to create test object: ${JSON.stringify(errors ?? data)}`,
);
}
return { id: data.createOneObject.id, input };
};
export const cleanupTestObject = async (id: string): Promise<void> => {
try {
await updateOneObjectMetadata({
expectToFail: false,
input: { idToUpdate: id, updatePayload: { isActive: false } },
});
await deleteOneObjectMetadata({
input: { idToDelete: id },
expectToFail: false,
});
} catch {}
};
export type CreateTestFieldInput = {
objectMetadataId: string;
type: FieldMetadataType;
name: string;
label: string;
isLabelSyncedWithName: boolean;
};
export const buildUniqueFieldInput = (
objectMetadataId: string,
): CreateTestFieldInput => {
const suffix = uniqueSuffix();
return {
objectMetadataId,
type: FieldMetadataType.TEXT,
name: `restTestField${suffix}`,
label: `Rest Test Field ${suffix}`,
isLabelSyncedWithName: false,
};
};
export const createTestFieldViaGraphql = async (
objectMetadataId: string,
overrides?: Partial<CreateTestFieldInput>,
): Promise<{ id: string; input: CreateTestFieldInput }> => {
const input = { ...buildUniqueFieldInput(objectMetadataId), ...overrides };
const { data, errors } = await createOneFieldMetadata({ input });
if (!data?.createOneField?.id) {
throw new Error(
`Failed to create test field: ${JSON.stringify(errors ?? data)}`,
);
}
return { id: data.createOneField.id, input };
};
export const cleanupTestField = async (id: string): Promise<void> => {
try {
await deleteOneFieldMetadata({
input: { idToDelete: id },
expectToFail: false,
});
} catch {
// best-effort cleanup
}
};
export type MetadataListPageInfo = {
hasNextPage: boolean;
hasPreviousPage: boolean;
startCursor: string | null;
endCursor: string | null;
};
export const extractMetadataListPayload = <T>(
body: Record<string, unknown>,
pluralKey: 'objects' | 'fields',
): { items: T[]; pageInfo: MetadataListPageInfo; totalCount: number } => {
if (Array.isArray(body.data)) {
return {
items: body.data as T[],
pageInfo: body.pageInfo as MetadataListPageInfo,
totalCount: body.totalCount as number,
};
}
const data = body.data as Record<string, unknown>;
return {
items: (data[pluralKey] ?? []) as T[],
pageInfo: body.pageInfo as MetadataListPageInfo,
totalCount: body.totalCount as number,
};
};
export const extractMetadataItemPayload = <T>(
body: Record<string, unknown>,
legacyKey: string,
): T => {
const data = body.data as Record<string, unknown> | undefined;
if (data && legacyKey in data) {
return data[legacyKey] as T;
}
return body as T;
};

View File

@@ -0,0 +1,78 @@
export interface RestResponse<T = Record<string, unknown>> {
status: number;
body: T & {
error?: string;
errors?: string[] | Record<string, unknown>[];
message?: string;
};
}
export const assertRestApiSuccessfulResponse = <T = Record<string, unknown>>(
response: RestResponse<T>,
expectedStatus = 200,
) => {
if (response.status !== expectedStatus) {
throw new Error(
`Expected status ${expectedStatus}, received ${response.status}: ${JSON.stringify(response.body)}`,
);
}
expect(response.status).toBe(expectedStatus);
expect(response.body).toBeDefined();
if (response.body.error || response.body.errors) {
throw new Error(
`Expected successful response but got errors: ${JSON.stringify(response.body)}`,
);
}
};
export const assertMetadataRestListResponse = <T>(
response: RestResponse<{
data: T[];
pageInfo: {
hasNextPage: boolean;
hasPreviousPage: boolean;
startCursor: string | null;
endCursor: string | null;
};
totalCount: number;
}>,
): T[] => {
assertRestApiSuccessfulResponse(response);
expect(Array.isArray(response.body.data)).toBe(true);
expect(response.body.pageInfo).toEqual({
hasNextPage: expect.any(Boolean),
hasPreviousPage: expect.any(Boolean),
startCursor: response.body.data.length > 0 ? expect.any(String) : null,
endCursor: response.body.data.length > 0 ? expect.any(String) : null,
});
expect(response.body.totalCount).toEqual(expect.any(Number));
return response.body.data;
};
export const assertRestApiErrorResponse = <T = Record<string, unknown>>(
response: RestResponse<T>,
expectedStatus = 400,
expectedErrorMessage?: string,
) => {
expect(response.status).toBe(expectedStatus);
if (expectedErrorMessage && response.body.message) {
expect(response.body.message).toContain(expectedErrorMessage);
}
};
export const assertRestApiErrorNotFoundResponse = (
response: RestResponse<{ statusCode: number; messages: [] }>,
expectedStatus = 404,
expectedErrorMessage?: string,
) => {
expect(response.status).toBe(expectedStatus);
expect(response.body.statusCode).toBe(expectedStatus);
if (expectedErrorMessage && response.body.message) {
expect(response.body.message).toContain(expectedErrorMessage);
}
};

View File

@@ -0,0 +1,294 @@
import { makeRestAPIRequest } from 'test/integration/rest/utils/make-rest-api-request.util';
import { generateRecordName } from 'test/integration/utils/generate-record-name';
import {
ViewOpenRecordIn,
ViewType,
ViewVisibility,
} from 'twenty-shared/types';
import { type ViewFieldEntity } from 'src/engine/metadata-modules/view-field/entities/view-field.entity';
import { type ViewFilterGroupEntity } from 'src/engine/metadata-modules/view-filter-group/entities/view-filter-group.entity';
import { type ViewFilterEntity } from 'src/engine/metadata-modules/view-filter/entities/view-filter.entity';
import { type ViewGroupEntity } from 'src/engine/metadata-modules/view-group/entities/view-group.entity';
import { type ViewSortEntity } from 'src/engine/metadata-modules/view-sort/entities/view-sort.entity';
import { type ViewEntity } from 'src/engine/metadata-modules/view/entities/view.entity';
export const findViewByIdWithRestApi = async (
viewId: string,
): Promise<ViewEntity | null> => {
const response = await makeRestAPIRequest({
method: 'get',
path: `/metadata/views/${viewId}`,
bearer: APPLE_JANE_ADMIN_ACCESS_TOKEN,
});
if (response.status === 404) {
return null;
}
return response.body;
};
export const findViewFilterWithRestApi = async (
viewFilterId: string,
): Promise<ViewFilterEntity | null> => {
const response = await makeRestAPIRequest({
method: 'get',
path: `/metadata/viewFilters/${viewFilterId}`,
bearer: APPLE_JANE_ADMIN_ACCESS_TOKEN,
});
if (response.status === 404) {
return null;
}
return response.body;
};
export const createTestViewWithRestApi = async (
params: {
objectMetadataId: string;
} & Partial<Omit<ViewEntity, 'objectMetadataId'>>,
): Promise<ViewEntity> => {
const { objectMetadataId, name, ...restParams } = params;
const viewData = {
name: name || generateRecordName('Test View'),
objectMetadataId,
icon: 'IconTable',
type: ViewType.TABLE,
key: null,
position: 0,
isCompact: false,
openRecordIn: ViewOpenRecordIn.SIDE_PANEL,
visibility: ViewVisibility.WORKSPACE,
...restParams,
};
const response = await makeRestAPIRequest({
method: 'post',
path: '/metadata/views',
body: viewData,
bearer: APPLE_JANE_ADMIN_ACCESS_TOKEN,
});
if (response.status !== 201) {
throw new Error(
`Failed to create test view: ${response.status} - ${JSON.stringify(response.body)}`,
);
}
return response.body;
};
export const createTestViewFieldWithRestApi = async (
params: {
viewId: string;
fieldMetadataId: string;
} & Partial<Omit<ViewFieldEntity, 'viewId' | 'fieldMetadataId'>>,
): Promise<ViewFieldEntity> => {
const { viewId, fieldMetadataId, ...restParams } = params;
const viewFieldData = {
viewId,
fieldMetadataId,
position: 0,
isVisible: true,
size: 150,
...restParams,
};
const response = await makeRestAPIRequest({
method: 'post',
path: '/metadata/viewFields',
body: viewFieldData,
bearer: APPLE_JANE_ADMIN_ACCESS_TOKEN,
});
if (response.status !== 201) {
throw new Error(
`Failed to create test view field: ${response.status} - ${JSON.stringify(response.body)}`,
);
}
return response.body;
};
export const createTestViewFilterWithRestApi = async (
params: {
viewId: string;
fieldMetadataId: string;
} & Partial<Omit<ViewFilterEntity, 'viewId' | 'fieldMetadataId'>>,
): Promise<ViewFilterEntity> => {
const { viewId, fieldMetadataId, operand, value, ...restParams } = params;
const viewFilterData = {
viewId,
fieldMetadataId,
operand: operand || 'Is',
value: value || 'test-value',
...restParams,
};
const response = await makeRestAPIRequest({
method: 'post',
path: '/metadata/viewFilters',
body: viewFilterData,
bearer: APPLE_JANE_ADMIN_ACCESS_TOKEN,
});
if (response.status !== 201) {
throw new Error(
`Failed to create test view filter: ${response.status} - ${JSON.stringify(response.body)}`,
);
}
return response.body;
};
export const createTestViewSortWithRestApi = async (
params: {
viewId: string;
fieldMetadataId: string;
} & Partial<Omit<ViewSortEntity, 'viewId' | 'fieldMetadataId'>>,
): Promise<ViewSortEntity> => {
const { viewId, fieldMetadataId, direction, ...restParams } = params;
const viewSortData = {
viewId,
fieldMetadataId,
direction: direction || 'ASC',
...restParams,
};
const response = await makeRestAPIRequest({
method: 'post',
path: '/metadata/viewSorts',
body: viewSortData,
bearer: APPLE_JANE_ADMIN_ACCESS_TOKEN,
});
if (response.status !== 201) {
throw new Error(
`Failed to create test view sort: ${response.status} - ${JSON.stringify(response.body)}`,
);
}
return response.body;
};
export const createTestViewGroupWithRestApi = async (
params: {
viewId: string;
fieldMetadataId: string;
} & Partial<Omit<ViewGroupEntity, 'viewId' | 'fieldMetadataId'>>,
): Promise<ViewGroupEntity> => {
const { viewId, fieldMetadataId, fieldValue, ...restParams } = params;
const viewGroupData = {
viewId,
fieldMetadataId,
isVisible: true,
fieldValue: fieldValue || 'test-group-value',
position: 0,
...restParams,
};
const response = await makeRestAPIRequest({
method: 'post',
path: '/metadata/viewGroups',
body: viewGroupData,
bearer: APPLE_JANE_ADMIN_ACCESS_TOKEN,
});
if (response.status !== 201) {
throw new Error(
`Failed to create test view group: ${response.status} - ${JSON.stringify(response.body)}`,
);
}
return response.body;
};
export const createTestViewFilterGroupWithRestApi = async (
params: {
viewId: string;
} & Partial<Omit<ViewFilterGroupEntity, 'viewId'>>,
): Promise<ViewFilterGroupEntity> => {
const { viewId, logicalOperator, ...restParams } = params;
const viewFilterGroupData = {
viewId,
logicalOperator: logicalOperator || 'AND',
...restParams,
};
const response = await makeRestAPIRequest({
method: 'post',
path: '/metadata/viewFilterGroups',
body: viewFilterGroupData,
bearer: APPLE_JANE_ADMIN_ACCESS_TOKEN,
});
if (response.status !== 201) {
throw new Error(
`Failed to create test view filter group: ${response.status} - ${JSON.stringify(response.body)}`,
);
}
return response.body;
};
export const deleteTestViewWithRestApi = async (
viewId: string,
): Promise<void> => {
await makeRestAPIRequest({
method: 'delete',
path: `/metadata/views/${viewId}`,
bearer: APPLE_JANE_ADMIN_ACCESS_TOKEN,
}).catch(() => {});
};
export const deleteTestViewFieldWithRestApi = async (
viewFieldId: string,
): Promise<void> => {
await makeRestAPIRequest({
method: 'delete',
path: `/metadata/viewFields/${viewFieldId}`,
bearer: APPLE_JANE_ADMIN_ACCESS_TOKEN,
}).catch(() => {});
};
export const deleteTestViewFilterWithRestApi = async (
viewFilterId: string,
): Promise<void> => {
await makeRestAPIRequest({
method: 'delete',
path: `/metadata/viewFilters/${viewFilterId}`,
bearer: APPLE_JANE_ADMIN_ACCESS_TOKEN,
}).catch(() => {});
};
export const deleteTestViewSortWithRestApi = async (
viewSortId: string,
): Promise<void> => {
await makeRestAPIRequest({
method: 'delete',
path: `/metadata/viewSorts/${viewSortId}`,
bearer: APPLE_JANE_ADMIN_ACCESS_TOKEN,
}).catch(() => {});
};
export const deleteTestViewGroupWithRestApi = async (
viewGroupId: string,
): Promise<void> => {
await makeRestAPIRequest({
method: 'delete',
path: `/metadata/viewGroups/${viewGroupId}`,
bearer: APPLE_JANE_ADMIN_ACCESS_TOKEN,
}).catch(() => {});
};
export const deleteTestViewFilterGroupWithRestApi = async (
viewFilterGroupId: string,
): Promise<void> => {
await makeRestAPIRequest({
method: 'delete',
path: `/metadata/viewFilterGroups/${viewFilterGroupId}`,
bearer: APPLE_JANE_ADMIN_ACCESS_TOKEN,
}).catch(() => {});
};