Files
twenty/packages/twenty-sdk/src/application/__tests__/define-function.spec.ts
T
martmull 40eef5c464 Improve application ast (#17016)
# Summary

- Introduces a new, flexible folder structure for Twenty SDK
applications using file suffix-based entity detection
- Adds defineApp, defineFunction, defineObject, and defineRole helper
functions with built-in validation
- Refactors manifest loading to use jiti runtime evaluation for
TypeScript config files
- Separates validation logic into dedicated module with comprehensive
error reporting

  # New Application Folder Structure

Applications now use a convention-over-configuration approach where
entities are detected by their file suffix, allowing flexible
organization within the src/app/ folder.

  # Required Structure

    my-app/
    ├── package.json
    ├── yarn.lock
    └── src/
        ├── app/
│ └── application.config.ts # Required - main application configuration
└── utils/ # Optional - handler implementations & utilities

  # Entity Detection by File Suffix

    - *.object.ts - Custom object definitions
    - *.function.ts - Serverless function definitions
    - *.role.ts - Role definitions

  # Supported Folder Organizations

  ## Traditional (by type):
    src/app/
    ├── application.config.ts
    ├── objects/
    │   └── postCard.object.ts
    ├── functions/
    │   └── createPostCard.function.ts
    └── roles/
        └── admin.role.ts

  ## Feature-based:
    src/app/
    ├── application.config.ts
    └── post-card/
        ├── postCard.object.ts
        ├── createPostCard.function.ts
        └── postCardAdmin.role.ts

  ## Flat:
    src/app/
    ├── application.config.ts
    ├── postCard.object.ts
    ├── createPostCard.function.ts
    └── admin.role.ts

  # New Helper Functions

  ## defineApp(config)

    import { defineApp } from 'twenty-sdk';

    export default defineApp({
      universalIdentifier: '4ec0391d-...',
      displayName: 'My App',
      description: 'App description',
      icon: 'IconWorld',
    });

  ## defineObject(config)

    import { defineObject, FieldType } from 'twenty-sdk';

    export default defineObject({
      universalIdentifier: '54b589ca-...',
      nameSingular: 'postCard',
      namePlural: 'postCards',
      labelSingular: 'Post Card',
      labelPlural: 'Post Cards',
      icon: 'IconMail',
      fields: [
        {
          universalIdentifier: '58a0a314-...',
          type: FieldType.TEXT,
          name: 'content',
          label: 'Content',
        },
      ],
    });

  ## defineFunction(config)

    import { defineFunction } from 'twenty-sdk';
    import { myHandler } from '../utils/my-handler';

    export default defineFunction({
      universalIdentifier: 'e56d363b-...',
      name: 'My Function',
      handler: myHandler,
      triggers: [
        {
          universalIdentifier: 'c9f84c8d-...',
          type: 'route',
          path: '/my-route',
          httpMethod: 'POST',
        },
      ],
    });

  ## defineRole(config)

    import { defineRole, PermissionFlag } from 'twenty-sdk';

    export default defineRole({
      universalIdentifier: 'b648f87b-...',
      label: 'App User',
      objectPermissions: [
        {
          objectNameSingular: 'postCard',
          canReadObjectRecords: true,
        },
      ],
      permissionFlags: [PermissionFlag.UPLOAD_FILE],
    });

  # Test plan

    - Verify npx twenty app sync works with new folder structure
    - Verify npx twenty app dev works with new folder structure
    - Verify validation errors display correctly for invalid configs
- Verify all three folder organization styles work (traditional,
feature-based, flat)
    - Run existing E2E tests to ensure backward compatibility
2026-01-09 13:06:30 +00:00

300 lines
8.0 KiB
TypeScript

import { defineFunction } from '../functions/define-function';
// Mock handler for testing
const mockHandler = async () => ({ success: true });
describe('defineFunction', () => {
const validRouteConfig = {
universalIdentifier: 'e56d363b-0bdc-4d8a-a393-6f0d1c75bdcf',
name: 'Send Postcard',
handler: mockHandler,
triggers: [
{
universalIdentifier: 'c9f84c8d-b26d-40d1-95dd-4f834ae5a2c6',
type: 'route' as const,
path: '/postcards/send',
httpMethod: 'POST' as const,
isAuthRequired: true,
},
],
};
it('should return the config when valid with route trigger', () => {
const result = defineFunction(validRouteConfig);
expect(result).toEqual(validRouteConfig);
});
it('should accept cron trigger', () => {
const config = {
universalIdentifier: 'e56d363b-0bdc-4d8a-a393-6f0d1c75bdcf',
name: 'Daily Report',
handler: mockHandler,
triggers: [
{
universalIdentifier: 'dd802808-0695-49e1-98c9-d5c9e2704ce2',
type: 'cron' as const,
pattern: '0 9 * * *',
},
],
};
const result = defineFunction(config);
expect(result.triggers[0].type).toBe('cron');
});
it('should accept databaseEvent trigger', () => {
const config = {
universalIdentifier: 'e56d363b-0bdc-4d8a-a393-6f0d1c75bdcf',
name: 'On Contact Created',
handler: mockHandler,
triggers: [
{
universalIdentifier: '203f1df3-4a82-4d06-a001-b8cf22a31156',
type: 'databaseEvent' as const,
eventName: 'contact.created',
},
],
};
const result = defineFunction(config);
expect(result.triggers[0].type).toBe('databaseEvent');
});
it('should accept multiple triggers', () => {
const config = {
universalIdentifier: 'e56d363b-0bdc-4d8a-a393-6f0d1c75bdcf',
name: 'Multi-trigger Function',
handler: mockHandler,
triggers: [
{
universalIdentifier: 'c9f84c8d-b26d-40d1-95dd-4f834ae5a2c6',
type: 'route' as const,
path: '/sync',
httpMethod: 'POST' as const,
isAuthRequired: true,
},
{
universalIdentifier: 'dd802808-0695-49e1-98c9-d5c9e2704ce2',
type: 'cron' as const,
pattern: '0 * * * *',
},
],
};
const result = defineFunction(config);
expect(result.triggers).toHaveLength(2);
});
it('should pass through optional fields', () => {
const config = {
...validRouteConfig,
description: 'Send a postcard to a contact',
timeoutSeconds: 30,
};
const result = defineFunction(config);
expect(result.description).toBe('Send a postcard to a contact');
expect(result.timeoutSeconds).toBe(30);
});
it('should throw error when universalIdentifier is missing', () => {
const config = {
name: 'Send Postcard',
handler: mockHandler,
triggers: validRouteConfig.triggers,
};
expect(() => defineFunction(config as any)).toThrow(
'Function must have a universalIdentifier',
);
});
it('should throw error when handler is missing', () => {
const config = {
universalIdentifier: 'e56d363b-0bdc-4d8a-a393-6f0d1c75bdcf',
name: 'Send Postcard',
triggers: validRouteConfig.triggers,
};
expect(() => defineFunction(config as any)).toThrow(
'Function must have a handler',
);
});
it('should throw error when handler is not a function', () => {
const config = {
universalIdentifier: 'e56d363b-0bdc-4d8a-a393-6f0d1c75bdcf',
name: 'Send Postcard',
handler: 'not-a-function',
triggers: validRouteConfig.triggers,
};
expect(() => defineFunction(config as any)).toThrow(
'Function must have a handler',
);
});
it('should throw error when triggers is empty', () => {
const config = {
universalIdentifier: 'e56d363b-0bdc-4d8a-a393-6f0d1c75bdcf',
name: 'Send Postcard',
handler: mockHandler,
triggers: [],
};
expect(() => defineFunction(config as any)).toThrow(
'Function must have at least one trigger',
);
});
it('should throw error when triggers is missing', () => {
const config = {
universalIdentifier: 'e56d363b-0bdc-4d8a-a393-6f0d1c75bdcf',
name: 'Send Postcard',
handler: mockHandler,
};
expect(() => defineFunction(config as any)).toThrow(
'Function must have at least one trigger',
);
});
it('should throw error when trigger is missing universalIdentifier', () => {
const config = {
universalIdentifier: 'e56d363b-0bdc-4d8a-a393-6f0d1c75bdcf',
name: 'Send Postcard',
handler: mockHandler,
triggers: [
{
type: 'route' as const,
path: '/postcards/send',
httpMethod: 'POST' as const,
isAuthRequired: true,
},
],
};
expect(() => defineFunction(config as any)).toThrow(
'Each trigger must have a universalIdentifier',
);
});
it('should throw error when trigger is missing type', () => {
const config = {
universalIdentifier: 'e56d363b-0bdc-4d8a-a393-6f0d1c75bdcf',
name: 'Send Postcard',
handler: mockHandler,
triggers: [
{
universalIdentifier: 'c9f84c8d-b26d-40d1-95dd-4f834ae5a2c6',
path: '/postcards/send',
httpMethod: 'POST' as const,
},
],
};
expect(() => defineFunction(config as any)).toThrow(
'Each trigger must have a type',
);
});
it('should throw error when route trigger is missing path', () => {
const config = {
universalIdentifier: 'e56d363b-0bdc-4d8a-a393-6f0d1c75bdcf',
name: 'Send Postcard',
handler: mockHandler,
triggers: [
{
universalIdentifier: 'c9f84c8d-b26d-40d1-95dd-4f834ae5a2c6',
type: 'route' as const,
httpMethod: 'POST' as const,
isAuthRequired: true,
},
],
};
expect(() => defineFunction(config as any)).toThrow(
'Route trigger must have a path',
);
});
it('should throw error when route trigger is missing httpMethod', () => {
const config = {
universalIdentifier: 'e56d363b-0bdc-4d8a-a393-6f0d1c75bdcf',
name: 'Send Postcard',
handler: mockHandler,
triggers: [
{
universalIdentifier: 'c9f84c8d-b26d-40d1-95dd-4f834ae5a2c6',
type: 'route' as const,
path: '/postcards/send',
isAuthRequired: true,
},
],
};
expect(() => defineFunction(config as any)).toThrow(
'Route trigger must have an httpMethod',
);
});
it('should throw error when cron trigger is missing pattern', () => {
const config = {
universalIdentifier: 'e56d363b-0bdc-4d8a-a393-6f0d1c75bdcf',
name: 'Daily Report',
handler: mockHandler,
triggers: [
{
universalIdentifier: 'dd802808-0695-49e1-98c9-d5c9e2704ce2',
type: 'cron' as const,
},
],
};
expect(() => defineFunction(config as any)).toThrow(
'Cron trigger must have a pattern',
);
});
it('should throw error when databaseEvent trigger is missing eventName', () => {
const config = {
universalIdentifier: 'e56d363b-0bdc-4d8a-a393-6f0d1c75bdcf',
name: 'On Contact Created',
handler: mockHandler,
triggers: [
{
universalIdentifier: '203f1df3-4a82-4d06-a001-b8cf22a31156',
type: 'databaseEvent' as const,
},
],
};
expect(() => defineFunction(config as any)).toThrow(
'Database event trigger must have an eventName',
);
});
it('should throw error for unknown trigger type', () => {
const config = {
universalIdentifier: 'e56d363b-0bdc-4d8a-a393-6f0d1c75bdcf',
name: 'Unknown Trigger',
handler: mockHandler,
triggers: [
{
universalIdentifier: 'c9f84c8d-b26d-40d1-95dd-4f834ae5a2c6',
type: 'unknown' as any,
},
],
};
expect(() => defineFunction(config as any)).toThrow(
'Unknown trigger type: unknown',
);
});
});