From 982c8787f84b0b4ef6840a3539a66e1be1e35fcf Mon Sep 17 00:00:00 2001 From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com> Date: Thu, 22 Jan 2026 04:43:34 +0000 Subject: [PATCH 1/6] Initial plan From e0d639f5b407a5d0133f7829c9abd858299055ba Mon Sep 17 00:00:00 2001 From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com> Date: Thu, 22 Jan 2026 04:47:20 +0000 Subject: [PATCH 2/6] Add comprehensive tests for dataset.zod.ts and mapping.zod.ts Co-authored-by: huangyiirene <7665279+huangyiirene@users.noreply.github.com> --- packages/spec/src/data/dataset.test.ts | 236 ++++++++++++++ packages/spec/src/data/mapping.test.ts | 422 +++++++++++++++++++++++++ 2 files changed, 658 insertions(+) create mode 100644 packages/spec/src/data/dataset.test.ts create mode 100644 packages/spec/src/data/mapping.test.ts diff --git a/packages/spec/src/data/dataset.test.ts b/packages/spec/src/data/dataset.test.ts new file mode 100644 index 0000000..8daf132 --- /dev/null +++ b/packages/spec/src/data/dataset.test.ts @@ -0,0 +1,236 @@ +import { describe, it, expect } from 'vitest'; +import { DatasetSchema, DatasetMode, type Dataset } from './dataset.zod'; + +describe('DatasetMode', () => { + it('should accept valid dataset modes', () => { + const validModes = ['insert', 'update', 'upsert', 'replace', 'ignore']; + + validModes.forEach(mode => { + expect(() => DatasetMode.parse(mode)).not.toThrow(); + }); + }); + + it('should reject invalid modes', () => { + expect(() => DatasetMode.parse('merge')).toThrow(); + expect(() => DatasetMode.parse('delete')).toThrow(); + expect(() => DatasetMode.parse('')).toThrow(); + }); +}); + +describe('DatasetSchema', () => { + it('should accept valid minimal dataset', () => { + const validDataset: Dataset = { + object: 'user', + records: [ + { name: 'John', email: 'john@example.com' }, + { name: 'Jane', email: 'jane@example.com' } + ] + }; + + expect(() => DatasetSchema.parse(validDataset)).not.toThrow(); + }); + + it('should accept dataset with all fields', () => { + const fullDataset: Dataset = { + object: 'account', + externalId: 'code', + mode: 'upsert', + env: ['prod', 'dev', 'test'], + records: [ + { code: 'ACC001', name: 'Acme Corp' }, + { code: 'ACC002', name: 'Beta Inc' } + ] + }; + + expect(() => DatasetSchema.parse(fullDataset)).not.toThrow(); + }); + + it('should apply default values', () => { + const dataset = DatasetSchema.parse({ + object: 'product', + records: [{ name: 'Widget' }] + }); + + expect(dataset.externalId).toBe('name'); + expect(dataset.mode).toBe('upsert'); + expect(dataset.env).toEqual(['prod', 'dev', 'test']); + }); + + it('should validate object name format (snake_case)', () => { + expect(() => DatasetSchema.parse({ + object: 'valid_object_name', + records: [] + })).not.toThrow(); + + expect(() => DatasetSchema.parse({ + object: 'InvalidObject', + records: [] + })).toThrow(); + + expect(() => DatasetSchema.parse({ + object: 'invalid-object', + records: [] + })).toThrow(); + }); + + it('should accept different modes', () => { + const modes: Array = ['insert', 'update', 'upsert', 'replace', 'ignore']; + + modes.forEach(mode => { + const dataset = DatasetSchema.parse({ + object: 'test_object', + mode, + records: [] + }); + expect(dataset.mode).toBe(mode); + }); + }); + + it('should accept environment scopes', () => { + const dataset1 = DatasetSchema.parse({ + object: 'test_object', + env: ['dev'], + records: [] + }); + expect(dataset1.env).toEqual(['dev']); + + const dataset2 = DatasetSchema.parse({ + object: 'test_object', + env: ['prod', 'test'], + records: [] + }); + expect(dataset2.env).toEqual(['prod', 'test']); + }); + + it('should reject invalid environment values', () => { + expect(() => DatasetSchema.parse({ + object: 'test_object', + env: ['production'], + records: [] + })).toThrow(); + + expect(() => DatasetSchema.parse({ + object: 'test_object', + env: ['staging'], + records: [] + })).toThrow(); + }); + + it('should accept empty records array', () => { + const dataset = DatasetSchema.parse({ + object: 'empty_table', + records: [] + }); + + expect(dataset.records).toEqual([]); + }); + + it('should accept records with various data types', () => { + const dataset = DatasetSchema.parse({ + object: 'mixed_data', + records: [ + { + string: 'text', + number: 42, + boolean: true, + null_value: null, + object: { nested: 'value' }, + array: [1, 2, 3] + } + ] + }); + + expect(dataset.records[0]).toHaveProperty('string', 'text'); + expect(dataset.records[0]).toHaveProperty('number', 42); + expect(dataset.records[0]).toHaveProperty('boolean', true); + }); + + it('should validate externalId field name', () => { + const validExternalIds = ['name', 'code', 'external_id', 'username', 'slug']; + + validExternalIds.forEach(externalId => { + expect(() => DatasetSchema.parse({ + object: 'test_object', + externalId, + records: [] + })).not.toThrow(); + }); + }); + + it('should handle seed data use case', () => { + const seedData = DatasetSchema.parse({ + object: 'country', + externalId: 'code', + mode: 'upsert', + env: ['prod', 'dev', 'test'], + records: [ + { code: 'US', name: 'United States' }, + { code: 'CA', name: 'Canada' }, + { code: 'MX', name: 'Mexico' } + ] + }); + + expect(seedData.records).toHaveLength(3); + expect(seedData.mode).toBe('upsert'); + }); + + it('should handle demo data use case', () => { + const demoData = DatasetSchema.parse({ + object: 'project', + externalId: 'name', + mode: 'replace', + env: ['dev'], + records: [ + { name: 'Demo Project 1', status: 'active' }, + { name: 'Demo Project 2', status: 'completed' } + ] + }); + + expect(demoData.env).toEqual(['dev']); + expect(demoData.mode).toBe('replace'); + }); + + it('should handle test data use case', () => { + const testData = DatasetSchema.parse({ + object: 'test_user', + mode: 'ignore', + env: ['test'], + records: [ + { name: 'Test User', email: 'test@example.com' } + ] + }); + + expect(testData.env).toEqual(['test']); + expect(testData.mode).toBe('ignore'); + }); + + it('should reject dataset without required fields', () => { + expect(() => DatasetSchema.parse({ + records: [] + })).toThrow(); + + expect(() => DatasetSchema.parse({ + object: 'test_object' + })).toThrow(); + }); + + it('should reject invalid mode value', () => { + expect(() => DatasetSchema.parse({ + object: 'test_object', + mode: 'invalid_mode', + records: [] + })).toThrow(); + }); + + it('should handle large datasets', () => { + const largeDataset = DatasetSchema.parse({ + object: 'bulk_data', + records: Array.from({ length: 1000 }, (_, i) => ({ + id: i, + name: `Record ${i}` + })) + }); + + expect(largeDataset.records).toHaveLength(1000); + }); +}); diff --git a/packages/spec/src/data/mapping.test.ts b/packages/spec/src/data/mapping.test.ts new file mode 100644 index 0000000..9fb69bb --- /dev/null +++ b/packages/spec/src/data/mapping.test.ts @@ -0,0 +1,422 @@ +import { describe, it, expect } from 'vitest'; +import { + MappingSchema, + FieldMappingSchema, + TransformType, + type Mapping, + type FieldMapping +} from './mapping.zod'; + +describe('TransformType', () => { + it('should accept valid transform types', () => { + const validTypes = ['none', 'constant', 'lookup', 'split', 'join', 'javascript', 'map']; + + validTypes.forEach(type => { + expect(() => TransformType.parse(type)).not.toThrow(); + }); + }); + + it('should reject invalid transform types', () => { + expect(() => TransformType.parse('custom')).toThrow(); + expect(() => TransformType.parse('transform')).toThrow(); + expect(() => TransformType.parse('')).toThrow(); + }); +}); + +describe('FieldMappingSchema', () => { + it('should accept valid minimal field mapping', () => { + const validMapping: FieldMapping = { + source: 'first_name', + target: 'firstName' + }; + + expect(() => FieldMappingSchema.parse(validMapping)).not.toThrow(); + }); + + it('should accept field mapping with single source and target', () => { + const mapping = FieldMappingSchema.parse({ + source: 'email', + target: 'email_address', + transform: 'none' + }); + + expect(mapping.source).toBe('email'); + expect(mapping.target).toBe('email_address'); + }); + + it('should accept field mapping with array sources', () => { + const mapping = FieldMappingSchema.parse({ + source: ['first_name', 'last_name'], + target: 'full_name', + transform: 'join', + params: { separator: ' ' } + }); + + expect(mapping.source).toEqual(['first_name', 'last_name']); + }); + + it('should accept field mapping with array targets', () => { + const mapping = FieldMappingSchema.parse({ + source: 'full_name', + target: ['first_name', 'last_name'], + transform: 'split', + params: { separator: ' ' } + }); + + expect(mapping.target).toEqual(['first_name', 'last_name']); + }); + + it('should apply default transform type', () => { + const mapping = FieldMappingSchema.parse({ + source: 'field1', + target: 'field2' + }); + + expect(mapping.transform).toBe('none'); + }); + + it('should accept constant transform', () => { + const mapping = FieldMappingSchema.parse({ + source: 'unused', + target: 'status', + transform: 'constant', + params: { value: 'active' } + }); + + expect(mapping.transform).toBe('constant'); + expect(mapping.params?.value).toBe('active'); + }); + + it('should accept lookup transform', () => { + const mapping = FieldMappingSchema.parse({ + source: 'account_name', + target: 'account_id', + transform: 'lookup', + params: { + object: 'account', + fromField: 'name', + toField: '_id', + autoCreate: false + } + }); + + expect(mapping.transform).toBe('lookup'); + expect(mapping.params?.object).toBe('account'); + expect(mapping.params?.fromField).toBe('name'); + expect(mapping.params?.toField).toBe('_id'); + }); + + it('should accept map transform', () => { + const mapping = FieldMappingSchema.parse({ + source: 'status', + target: 'status_code', + transform: 'map', + params: { + valueMap: { + 'Open': 'open', + 'In Progress': 'in_progress', + 'Closed': 'closed' + } + } + }); + + expect(mapping.transform).toBe('map'); + expect(mapping.params?.valueMap).toHaveProperty('Open', 'open'); + }); + + it('should accept split transform', () => { + const mapping = FieldMappingSchema.parse({ + source: 'full_name', + target: ['first_name', 'last_name'], + transform: 'split', + params: { separator: ' ' } + }); + + expect(mapping.transform).toBe('split'); + expect(mapping.params?.separator).toBe(' '); + }); + + it('should accept join transform', () => { + const mapping = FieldMappingSchema.parse({ + source: ['street', 'city', 'zip'], + target: 'full_address', + transform: 'join', + params: { separator: ', ' } + }); + + expect(mapping.transform).toBe('join'); + expect(mapping.params?.separator).toBe(', '); + }); + + it('should accept javascript transform', () => { + const mapping = FieldMappingSchema.parse({ + source: 'raw_data', + target: 'processed_data', + transform: 'javascript', + params: { value: 'return value.toUpperCase();' } + }); + + expect(mapping.transform).toBe('javascript'); + }); +}); + +describe('MappingSchema', () => { + it('should accept valid minimal mapping', () => { + const validMapping: Mapping = { + name: 'csv_import', + targetObject: 'contact', + fieldMapping: [ + { source: 'email', target: 'email' } + ] + }; + + expect(() => MappingSchema.parse(validMapping)).not.toThrow(); + }); + + it('should accept mapping with all fields', () => { + const fullMapping: Mapping = { + name: 'contact_import', + label: 'Contact CSV Import', + sourceFormat: 'csv', + targetObject: 'contact', + fieldMapping: [ + { source: 'email', target: 'email' }, + { source: 'name', target: 'full_name' } + ], + mode: 'upsert', + upsertKey: ['email'], + errorPolicy: 'skip', + batchSize: 500 + }; + + expect(() => MappingSchema.parse(fullMapping)).not.toThrow(); + }); + + it('should validate mapping name format (snake_case)', () => { + expect(() => MappingSchema.parse({ + name: 'valid_mapping_name', + targetObject: 'object', + fieldMapping: [] + })).not.toThrow(); + + expect(() => MappingSchema.parse({ + name: 'InvalidMapping', + targetObject: 'object', + fieldMapping: [] + })).toThrow(); + + expect(() => MappingSchema.parse({ + name: 'invalid-mapping', + targetObject: 'object', + fieldMapping: [] + })).toThrow(); + }); + + it('should apply default values', () => { + const mapping = MappingSchema.parse({ + name: 'test_mapping', + targetObject: 'contact', + fieldMapping: [] + }); + + expect(mapping.sourceFormat).toBe('csv'); + expect(mapping.mode).toBe('insert'); + expect(mapping.errorPolicy).toBe('skip'); + expect(mapping.batchSize).toBe(1000); + }); + + it('should accept different source formats', () => { + const formats: Array = ['csv', 'json', 'xml', 'sql']; + + formats.forEach(format => { + const mapping = MappingSchema.parse({ + name: 'test_mapping', + sourceFormat: format, + targetObject: 'object', + fieldMapping: [] + }); + expect(mapping.sourceFormat).toBe(format); + }); + }); + + it('should accept different modes', () => { + const modes: Array = ['insert', 'update', 'upsert']; + + modes.forEach(mode => { + const mapping = MappingSchema.parse({ + name: 'test_mapping', + targetObject: 'object', + fieldMapping: [], + mode + }); + expect(mapping.mode).toBe(mode); + }); + }); + + it('should accept upsertKey with multiple fields', () => { + const mapping = MappingSchema.parse({ + name: 'test_mapping', + targetObject: 'contact', + fieldMapping: [], + mode: 'upsert', + upsertKey: ['email', 'phone'] + }); + + expect(mapping.upsertKey).toEqual(['email', 'phone']); + }); + + it('should accept different error policies', () => { + const policies: Array = ['skip', 'abort', 'retry']; + + policies.forEach(policy => { + const mapping = MappingSchema.parse({ + name: 'test_mapping', + targetObject: 'object', + fieldMapping: [], + errorPolicy: policy + }); + expect(mapping.errorPolicy).toBe(policy); + }); + }); + + it('should accept custom batch size', () => { + const mapping = MappingSchema.parse({ + name: 'test_mapping', + targetObject: 'object', + fieldMapping: [], + batchSize: 100 + }); + + expect(mapping.batchSize).toBe(100); + }); + + it('should accept extractQuery for export', () => { + const mapping = MappingSchema.parse({ + name: 'export_mapping', + targetObject: 'contact', + fieldMapping: [{ source: 'email', target: 'email' }], + extractQuery: { + object: 'contact', + fields: ['_id', 'email', 'name'], + filters: ['status', '=', 'active'] + } + }); + + expect(mapping.extractQuery).toBeDefined(); + expect(mapping.extractQuery?.object).toBe('contact'); + }); + + it('should handle CSV import mapping', () => { + const csvMapping = MappingSchema.parse({ + name: 'csv_contact_import', + sourceFormat: 'csv', + targetObject: 'contact', + fieldMapping: [ + { source: 'Email', target: 'email' }, + { source: 'First Name', target: 'first_name' }, + { source: 'Last Name', target: 'last_name' } + ], + mode: 'upsert', + upsertKey: ['email'] + }); + + expect(csvMapping.sourceFormat).toBe('csv'); + expect(csvMapping.fieldMapping).toHaveLength(3); + }); + + it('should handle JSON import mapping', () => { + const jsonMapping = MappingSchema.parse({ + name: 'json_import', + sourceFormat: 'json', + targetObject: 'product', + fieldMapping: [ + { source: 'sku', target: 'product_code' }, + { source: 'name', target: 'product_name' } + ] + }); + + expect(jsonMapping.sourceFormat).toBe('json'); + }); + + it('should handle complex field mappings', () => { + const complexMapping = MappingSchema.parse({ + name: 'complex_import', + targetObject: 'contact', + fieldMapping: [ + { + source: 'email', + target: 'email', + transform: 'none' + }, + { + source: 'unused', + target: 'status', + transform: 'constant', + params: { value: 'active' } + }, + { + source: 'account_name', + target: 'account_id', + transform: 'lookup', + params: { + object: 'account', + fromField: 'name', + toField: '_id' + } + }, + { + source: ['first_name', 'last_name'], + target: 'full_name', + transform: 'join', + params: { separator: ' ' } + } + ] + }); + + expect(complexMapping.fieldMapping).toHaveLength(4); + }); + + it('should reject mapping without required fields', () => { + expect(() => MappingSchema.parse({ + targetObject: 'object', + fieldMapping: [] + })).toThrow(); + + expect(() => MappingSchema.parse({ + name: 'test_mapping', + fieldMapping: [] + })).toThrow(); + + expect(() => MappingSchema.parse({ + name: 'test_mapping', + targetObject: 'object' + })).toThrow(); + }); + + it('should reject invalid source format', () => { + expect(() => MappingSchema.parse({ + name: 'test_mapping', + sourceFormat: 'excel', + targetObject: 'object', + fieldMapping: [] + })).toThrow(); + }); + + it('should reject invalid mode', () => { + expect(() => MappingSchema.parse({ + name: 'test_mapping', + targetObject: 'object', + fieldMapping: [], + mode: 'merge' + })).toThrow(); + }); + + it('should reject invalid error policy', () => { + expect(() => MappingSchema.parse({ + name: 'test_mapping', + targetObject: 'object', + fieldMapping: [], + errorPolicy: 'ignore' + })).toThrow(); + }); +}); From c4d9d73d8528e13970d1c1ee680c00e7e316e301 Mon Sep 17 00:00:00 2001 From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com> Date: Thu, 22 Jan 2026 04:51:20 +0000 Subject: [PATCH 3/6] Add comprehensive tests for system protocols: policy, territory, license, webhook, translation, discovery Co-authored-by: huangyiirene <7665279+huangyiirene@users.noreply.github.com> --- packages/spec/src/system/discovery.test.ts | 434 +++++++++++++++++ packages/spec/src/system/license.test.ts | 485 +++++++++++++++++++ packages/spec/src/system/policy.test.ts | 347 +++++++++++++ packages/spec/src/system/territory.test.ts | 309 ++++++++++++ packages/spec/src/system/translation.test.ts | 392 +++++++++++++++ packages/spec/src/system/webhook.test.ts | 447 +++++++++++++++++ 6 files changed, 2414 insertions(+) create mode 100644 packages/spec/src/system/discovery.test.ts create mode 100644 packages/spec/src/system/license.test.ts create mode 100644 packages/spec/src/system/policy.test.ts create mode 100644 packages/spec/src/system/territory.test.ts create mode 100644 packages/spec/src/system/translation.test.ts create mode 100644 packages/spec/src/system/webhook.test.ts diff --git a/packages/spec/src/system/discovery.test.ts b/packages/spec/src/system/discovery.test.ts new file mode 100644 index 0000000..94b6285 --- /dev/null +++ b/packages/spec/src/system/discovery.test.ts @@ -0,0 +1,434 @@ +import { describe, it, expect } from 'vitest'; +import { + DiscoverySchema, + ApiRoutesSchema, + ApiCapabilitiesSchema, + type DiscoveryResponse, + type ApiRoutes, + type ApiCapabilities, +} from './discovery.zod'; + +describe('ApiCapabilitiesSchema', () => { + it('should accept valid capabilities', () => { + const capabilities: ApiCapabilities = { + graphql: false, + search: false, + websockets: false, + files: true, + }; + + expect(() => ApiCapabilitiesSchema.parse(capabilities)).not.toThrow(); + }); + + it('should apply default values', () => { + const capabilities = ApiCapabilitiesSchema.parse({}); + + expect(capabilities.graphql).toBe(false); + expect(capabilities.search).toBe(false); + expect(capabilities.websockets).toBe(false); + expect(capabilities.files).toBe(true); + }); + + it('should accept enabled features', () => { + const capabilities = ApiCapabilitiesSchema.parse({ + graphql: true, + search: true, + websockets: true, + files: true, + }); + + expect(capabilities.graphql).toBe(true); + expect(capabilities.search).toBe(true); + }); + + it('should handle minimal capabilities', () => { + const capabilities = ApiCapabilitiesSchema.parse({ + graphql: false, + search: false, + websockets: false, + files: false, + }); + + expect(capabilities.files).toBe(false); + }); +}); + +describe('ApiRoutesSchema', () => { + it('should accept valid minimal routes', () => { + const routes: ApiRoutes = { + data: '/api/v1/data', + metadata: '/api/v1/meta', + auth: '/api/v1/auth', + }; + + expect(() => ApiRoutesSchema.parse(routes)).not.toThrow(); + }); + + it('should accept routes with all fields', () => { + const routes = ApiRoutesSchema.parse({ + data: '/api/v1/data', + metadata: '/api/v1/meta', + auth: '/api/v1/auth', + actions: '/api/v1/p', + storage: '/api/v1/storage', + graphql: '/api/v1/graphql', + }); + + expect(routes.data).toBe('/api/v1/data'); + expect(routes.graphql).toBe('/api/v1/graphql'); + }); + + it('should accept custom route paths', () => { + const routes = ApiRoutesSchema.parse({ + data: '/data', + metadata: '/metadata', + auth: '/auth', + }); + + expect(routes.data).toBe('/data'); + }); + + it('should accept versioned routes', () => { + const routes = ApiRoutesSchema.parse({ + data: '/api/v2/data', + metadata: '/api/v2/meta', + auth: '/api/v2/auth', + }); + + expect(routes.data).toBe('/api/v2/data'); + }); + + it('should reject routes without required fields', () => { + expect(() => ApiRoutesSchema.parse({ + metadata: '/api/v1/meta', + auth: '/api/v1/auth', + })).toThrow(); + + expect(() => ApiRoutesSchema.parse({ + data: '/api/v1/data', + auth: '/api/v1/auth', + })).toThrow(); + + expect(() => ApiRoutesSchema.parse({ + data: '/api/v1/data', + metadata: '/api/v1/meta', + })).toThrow(); + }); +}); + +describe('DiscoverySchema', () => { + it('should accept valid minimal discovery response', () => { + const discovery: DiscoveryResponse = { + name: 'ObjectStack', + version: '1.0.0', + environment: 'production', + routes: { + data: '/api/v1/data', + metadata: '/api/v1/meta', + auth: '/api/v1/auth', + }, + features: { + graphql: false, + search: false, + websockets: false, + files: true, + }, + locale: { + default: 'en-US', + supported: ['en-US', 'zh-CN'], + timezone: 'UTC', + }, + }; + + expect(() => DiscoverySchema.parse(discovery)).not.toThrow(); + }); + + it('should accept discovery with all fields', () => { + const discovery = DiscoverySchema.parse({ + name: 'ObjectStack Platform', + version: '2.0.0', + environment: 'production', + routes: { + data: '/api/v1/data', + metadata: '/api/v1/meta', + auth: '/api/v1/auth', + actions: '/api/v1/p', + storage: '/api/v1/storage', + graphql: '/api/v1/graphql', + }, + features: { + graphql: true, + search: true, + websockets: true, + files: true, + }, + locale: { + default: 'en-US', + supported: ['en-US', 'zh-CN', 'es-ES', 'fr-FR'], + timezone: 'America/Los_Angeles', + }, + }); + + expect(discovery.name).toBe('ObjectStack Platform'); + expect(discovery.version).toBe('2.0.0'); + }); + + it('should accept different environment values', () => { + const environments: Array = ['production', 'sandbox', 'development']; + + environments.forEach(environment => { + const discovery = DiscoverySchema.parse({ + name: 'ObjectStack', + version: '1.0.0', + environment, + routes: { + data: '/api/v1/data', + metadata: '/api/v1/meta', + auth: '/api/v1/auth', + }, + features: {}, + locale: { + default: 'en-US', + supported: ['en-US'], + timezone: 'UTC', + }, + }); + expect(discovery.environment).toBe(environment); + }); + }); + + it('should reject invalid environment', () => { + expect(() => DiscoverySchema.parse({ + name: 'ObjectStack', + version: '1.0.0', + environment: 'staging', + routes: { + data: '/api/v1/data', + metadata: '/api/v1/meta', + auth: '/api/v1/auth', + }, + features: {}, + locale: { + default: 'en-US', + supported: ['en-US'], + timezone: 'UTC', + }, + })).toThrow(); + }); + + it('should handle production environment', () => { + const discovery = DiscoverySchema.parse({ + name: 'ObjectStack Production', + version: '1.0.0', + environment: 'production', + routes: { + data: '/api/v1/data', + metadata: '/api/v1/meta', + auth: '/api/v1/auth', + }, + features: { + graphql: true, + search: true, + websockets: true, + files: true, + }, + locale: { + default: 'en-US', + supported: ['en-US', 'zh-CN', 'es-ES'], + timezone: 'UTC', + }, + }); + + expect(discovery.environment).toBe('production'); + }); + + it('should handle sandbox environment', () => { + const discovery = DiscoverySchema.parse({ + name: 'ObjectStack Sandbox', + version: '1.0.0-sandbox', + environment: 'sandbox', + routes: { + data: '/api/v1/data', + metadata: '/api/v1/meta', + auth: '/api/v1/auth', + }, + features: {}, + locale: { + default: 'en-US', + supported: ['en-US'], + timezone: 'UTC', + }, + }); + + expect(discovery.environment).toBe('sandbox'); + }); + + it('should handle development environment', () => { + const discovery = DiscoverySchema.parse({ + name: 'ObjectStack Dev', + version: '0.1.0-dev', + environment: 'development', + routes: { + data: '/api/v1/data', + metadata: '/api/v1/meta', + auth: '/api/v1/auth', + }, + features: {}, + locale: { + default: 'en-US', + supported: ['en-US'], + timezone: 'America/Los_Angeles', + }, + }); + + expect(discovery.environment).toBe('development'); + }); + + it('should handle locale configuration', () => { + const discovery = DiscoverySchema.parse({ + name: 'ObjectStack', + version: '1.0.0', + environment: 'production', + routes: { + data: '/api/v1/data', + metadata: '/api/v1/meta', + auth: '/api/v1/auth', + }, + features: {}, + locale: { + default: 'zh-CN', + supported: ['en-US', 'zh-CN', 'ja-JP'], + timezone: 'Asia/Shanghai', + }, + }); + + expect(discovery.locale.default).toBe('zh-CN'); + expect(discovery.locale.supported).toHaveLength(3); + expect(discovery.locale.timezone).toBe('Asia/Shanghai'); + }); + + it('should handle timezone configuration', () => { + const timezones = [ + 'UTC', + 'America/New_York', + 'America/Los_Angeles', + 'Europe/London', + 'Asia/Tokyo', + 'Australia/Sydney', + ]; + + timezones.forEach(timezone => { + const discovery = DiscoverySchema.parse({ + name: 'ObjectStack', + version: '1.0.0', + environment: 'production', + routes: { + data: '/api/v1/data', + metadata: '/api/v1/meta', + auth: '/api/v1/auth', + }, + features: {}, + locale: { + default: 'en-US', + supported: ['en-US'], + timezone, + }, + }); + expect(discovery.locale.timezone).toBe(timezone); + }); + }); + + it('should handle multiple supported locales', () => { + const discovery = DiscoverySchema.parse({ + name: 'ObjectStack', + version: '1.0.0', + environment: 'production', + routes: { + data: '/api/v1/data', + metadata: '/api/v1/meta', + auth: '/api/v1/auth', + }, + features: {}, + locale: { + default: 'en-US', + supported: [ + 'en-US', + 'en-GB', + 'zh-CN', + 'zh-TW', + 'es-ES', + 'es-MX', + 'fr-FR', + 'de-DE', + 'ja-JP', + 'ko-KR', + ], + timezone: 'UTC', + }, + }); + + expect(discovery.locale.supported).toHaveLength(10); + }); + + it('should handle GraphQL-enabled instance', () => { + const discovery = DiscoverySchema.parse({ + name: 'ObjectStack', + version: '1.0.0', + environment: 'production', + routes: { + data: '/api/v1/data', + metadata: '/api/v1/meta', + auth: '/api/v1/auth', + graphql: '/api/v1/graphql', + }, + features: { + graphql: true, + search: true, + websockets: false, + files: true, + }, + locale: { + default: 'en-US', + supported: ['en-US'], + timezone: 'UTC', + }, + }); + + expect(discovery.features.graphql).toBe(true); + expect(discovery.routes.graphql).toBe('/api/v1/graphql'); + }); + + it('should reject discovery without required fields', () => { + expect(() => DiscoverySchema.parse({ + version: '1.0.0', + environment: 'production', + routes: { + data: '/api/v1/data', + metadata: '/api/v1/meta', + auth: '/api/v1/auth', + }, + features: {}, + locale: { + default: 'en-US', + supported: ['en-US'], + timezone: 'UTC', + }, + })).toThrow(); + + expect(() => DiscoverySchema.parse({ + name: 'ObjectStack', + environment: 'production', + routes: { + data: '/api/v1/data', + metadata: '/api/v1/meta', + auth: '/api/v1/auth', + }, + features: {}, + locale: { + default: 'en-US', + supported: ['en-US'], + timezone: 'UTC', + }, + })).toThrow(); + }); +}); diff --git a/packages/spec/src/system/license.test.ts b/packages/spec/src/system/license.test.ts new file mode 100644 index 0000000..e9b2e3c --- /dev/null +++ b/packages/spec/src/system/license.test.ts @@ -0,0 +1,485 @@ +import { describe, it, expect } from 'vitest'; +import { + LicenseSchema, + PlanSchema, + FeatureSchema, + MetricType, + type License, + type Plan, + type Feature, +} from './license.zod'; + +describe('MetricType', () => { + it('should accept valid metric types', () => { + const validTypes = ['boolean', 'counter', 'gauge']; + + validTypes.forEach(type => { + expect(() => MetricType.parse(type)).not.toThrow(); + }); + }); + + it('should reject invalid metric types', () => { + expect(() => MetricType.parse('string')).toThrow(); + expect(() => MetricType.parse('number')).toThrow(); + expect(() => MetricType.parse('')).toThrow(); + }); +}); + +describe('FeatureSchema', () => { + it('should accept valid minimal feature', () => { + const feature: Feature = { + code: 'core.api_access', + label: 'API Access', + }; + + expect(() => FeatureSchema.parse(feature)).not.toThrow(); + }); + + it('should validate feature code format', () => { + expect(() => FeatureSchema.parse({ + code: 'valid.feature_code', + label: 'Valid Feature', + })).not.toThrow(); + + expect(() => FeatureSchema.parse({ + code: 'nested.feature.code', + label: 'Nested Feature', + })).not.toThrow(); + + expect(() => FeatureSchema.parse({ + code: 'Invalid-Feature', + label: 'Invalid', + })).toThrow(); + }); + + it('should apply default type', () => { + const feature = FeatureSchema.parse({ + code: 'feature.test', + label: 'Test Feature', + }); + + expect(feature.type).toBe('boolean'); + }); + + it('should accept feature with all fields', () => { + const feature = FeatureSchema.parse({ + code: 'storage.bytes_used', + label: 'Storage Used', + description: 'Total storage consumed in bytes', + type: 'gauge', + unit: 'bytes', + requires: ['enterprise_tier'], + }); + + expect(feature.type).toBe('gauge'); + expect(feature.unit).toBe('bytes'); + expect(feature.requires).toEqual(['enterprise_tier']); + }); + + it('should accept different metric types', () => { + const types: Array = ['boolean', 'counter', 'gauge']; + + types.forEach(type => { + const feature = FeatureSchema.parse({ + code: 'test.feature', + label: 'Test', + type, + }); + expect(feature.type).toBe(type); + }); + }); + + it('should accept different units', () => { + const units: Array> = ['count', 'bytes', 'seconds', 'percent']; + + units.forEach(unit => { + const feature = FeatureSchema.parse({ + code: 'test.feature', + label: 'Test', + unit, + }); + expect(feature.unit).toBe(unit); + }); + }); + + it('should accept feature dependencies', () => { + const feature = FeatureSchema.parse({ + code: 'advanced.audit_log', + label: 'Audit Log', + requires: ['enterprise_tier', 'security_module'], + }); + + expect(feature.requires).toEqual(['enterprise_tier', 'security_module']); + }); + + it('should handle boolean feature flag', () => { + const feature = FeatureSchema.parse({ + code: 'features.api_enabled', + label: 'API Enabled', + type: 'boolean', + }); + + expect(feature.type).toBe('boolean'); + }); + + it('should handle counter metric', () => { + const feature = FeatureSchema.parse({ + code: 'metrics.api_calls', + label: 'API Calls', + type: 'counter', + unit: 'count', + }); + + expect(feature.type).toBe('counter'); + expect(feature.unit).toBe('count'); + }); + + it('should handle gauge metric', () => { + const feature = FeatureSchema.parse({ + code: 'metrics.active_users', + label: 'Active Users', + type: 'gauge', + unit: 'count', + }); + + expect(feature.type).toBe('gauge'); + }); +}); + +describe('PlanSchema', () => { + it('should accept valid minimal plan', () => { + const plan: Plan = { + code: 'free', + label: 'Free Plan', + features: [], + limits: {}, + }; + + expect(() => PlanSchema.parse(plan)).not.toThrow(); + }); + + it('should apply default values', () => { + const plan = PlanSchema.parse({ + code: 'basic', + label: 'Basic Plan', + features: [], + limits: {}, + }); + + expect(plan.active).toBe(true); + }); + + it('should accept plan with all fields', () => { + const plan = PlanSchema.parse({ + code: 'pro_v1', + label: 'Professional', + active: true, + features: ['api_access', 'advanced_reporting', 'custom_branding'], + limits: { + storage_gb: 100, + api_calls_per_month: 100000, + users: 50, + }, + currency: 'USD', + priceMonthly: 49, + priceYearly: 490, + }); + + expect(plan.features).toHaveLength(3); + expect(plan.limits.storage_gb).toBe(100); + expect(plan.priceMonthly).toBe(49); + }); + + it('should accept free plan', () => { + const plan = PlanSchema.parse({ + code: 'free', + label: 'Free Plan', + features: ['basic_features'], + limits: { + storage_gb: 1, + api_calls_per_month: 1000, + users: 3, + }, + }); + + expect(plan.limits.users).toBe(3); + }); + + it('should accept professional plan', () => { + const plan = PlanSchema.parse({ + code: 'pro', + label: 'Professional', + features: ['api_access', 'advanced_reporting'], + limits: { + storage_gb: 100, + api_calls_per_month: 100000, + users: 50, + }, + priceMonthly: 99, + priceYearly: 990, + }); + + expect(plan.priceMonthly).toBe(99); + expect(plan.priceYearly).toBe(990); + }); + + it('should accept enterprise plan', () => { + const plan = PlanSchema.parse({ + code: 'enterprise', + label: 'Enterprise', + features: [ + 'api_access', + 'advanced_reporting', + 'custom_branding', + 'sso', + 'audit_logs', + 'dedicated_support', + ], + limits: { + storage_gb: 1000, + api_calls_per_month: 10000000, + users: -1, // Unlimited + }, + priceMonthly: 999, + }); + + expect(plan.features).toHaveLength(6); + expect(plan.limits.users).toBe(-1); + }); + + it('should accept inactive plan', () => { + const plan = PlanSchema.parse({ + code: 'legacy_v1', + label: 'Legacy Plan', + active: false, + features: [], + limits: {}, + }); + + expect(plan.active).toBe(false); + }); + + it('should accept different currencies', () => { + const plan = PlanSchema.parse({ + code: 'euro_plan', + label: 'Euro Plan', + features: [], + limits: {}, + currency: 'EUR', + priceMonthly: 39, + }); + + expect(plan.currency).toBe('EUR'); + }); + + it('should reject plan without required fields', () => { + expect(() => PlanSchema.parse({ + label: 'Test Plan', + features: [], + limits: {}, + })).toThrow(); + + expect(() => PlanSchema.parse({ + code: 'test', + features: [], + limits: {}, + })).toThrow(); + }); +}); + +describe('LicenseSchema', () => { + it('should accept valid minimal license', () => { + const license: License = { + tenantId: 'tenant123', + planCode: 'pro', + issuedAt: '2024-01-01T00:00:00Z', + status: 'active', + }; + + expect(() => LicenseSchema.parse(license)).not.toThrow(); + }); + + it('should accept license with all fields', () => { + const license = LicenseSchema.parse({ + tenantId: 'tenant456', + planCode: 'enterprise', + issuedAt: '2024-01-01T00:00:00Z', + expiresAt: '2025-01-01T00:00:00Z', + status: 'active', + customFeatures: ['custom_integration', 'beta_features'], + customLimits: { + storage_gb: 5000, + users: 500, + }, + signature: 'eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9...', + }); + + expect(license.customFeatures).toHaveLength(2); + expect(license.customLimits?.storage_gb).toBe(5000); + expect(license.signature).toBeDefined(); + }); + + it('should accept different status values', () => { + const statuses: Array = ['active', 'expired', 'suspended', 'trial']; + + statuses.forEach(status => { + const license = LicenseSchema.parse({ + tenantId: 'tenant123', + planCode: 'pro', + issuedAt: '2024-01-01T00:00:00Z', + status, + }); + expect(license.status).toBe(status); + }); + }); + + it('should accept perpetual license (no expiration)', () => { + const license = LicenseSchema.parse({ + tenantId: 'tenant123', + planCode: 'enterprise', + issuedAt: '2024-01-01T00:00:00Z', + status: 'active', + }); + + expect(license.expiresAt).toBeUndefined(); + }); + + it('should accept trial license', () => { + const license = LicenseSchema.parse({ + tenantId: 'tenant789', + planCode: 'pro', + issuedAt: '2024-01-01T00:00:00Z', + expiresAt: '2024-02-01T00:00:00Z', + status: 'trial', + }); + + expect(license.status).toBe('trial'); + expect(license.expiresAt).toBe('2024-02-01T00:00:00Z'); + }); + + it('should accept expired license', () => { + const license = LicenseSchema.parse({ + tenantId: 'tenant123', + planCode: 'pro', + issuedAt: '2023-01-01T00:00:00Z', + expiresAt: '2024-01-01T00:00:00Z', + status: 'expired', + }); + + expect(license.status).toBe('expired'); + }); + + it('should accept suspended license', () => { + const license = LicenseSchema.parse({ + tenantId: 'tenant123', + planCode: 'pro', + issuedAt: '2024-01-01T00:00:00Z', + status: 'suspended', + }); + + expect(license.status).toBe('suspended'); + }); + + it('should accept custom feature overrides', () => { + const license = LicenseSchema.parse({ + tenantId: 'tenant123', + planCode: 'pro', + issuedAt: '2024-01-01T00:00:00Z', + status: 'active', + customFeatures: ['beta_feature_1', 'experimental_feature_2'], + }); + + expect(license.customFeatures).toEqual(['beta_feature_1', 'experimental_feature_2']); + }); + + it('should accept custom limit overrides', () => { + const license = LicenseSchema.parse({ + tenantId: 'tenant123', + planCode: 'pro', + issuedAt: '2024-01-01T00:00:00Z', + status: 'active', + customLimits: { + storage_gb: 250, + api_calls_per_month: 500000, + }, + }); + + expect(license.customLimits?.storage_gb).toBe(250); + expect(license.customLimits?.api_calls_per_month).toBe(500000); + }); + + it('should accept signed license with JWT', () => { + const license = LicenseSchema.parse({ + tenantId: 'tenant123', + planCode: 'enterprise', + issuedAt: '2024-01-01T00:00:00Z', + status: 'active', + signature: 'eyJhbGciOiJSUzI1NiIsInR5cCI6IkpXVCJ9.eyJzdWIiOiIxMjM0NTY3ODkwIiwibmFtZSI6IkpvaG4gRG9lIiwiYWRtaW4iOnRydWUsImlhdCI6MTUxNjIzOTAyMn0.signature', + }); + + expect(license.signature).toBeDefined(); + }); + + it('should validate datetime format for issuedAt', () => { + expect(() => LicenseSchema.parse({ + tenantId: 'tenant123', + planCode: 'pro', + issuedAt: '2024-01-01', + status: 'active', + })).toThrow(); + + expect(() => LicenseSchema.parse({ + tenantId: 'tenant123', + planCode: 'pro', + issuedAt: '2024-01-01T00:00:00Z', + status: 'active', + })).not.toThrow(); + }); + + it('should validate datetime format for expiresAt', () => { + expect(() => LicenseSchema.parse({ + tenantId: 'tenant123', + planCode: 'pro', + issuedAt: '2024-01-01T00:00:00Z', + expiresAt: '2025-01-01', + status: 'active', + })).toThrow(); + + expect(() => LicenseSchema.parse({ + tenantId: 'tenant123', + planCode: 'pro', + issuedAt: '2024-01-01T00:00:00Z', + expiresAt: '2025-01-01T00:00:00Z', + status: 'active', + })).not.toThrow(); + }); + + it('should reject license without required fields', () => { + expect(() => LicenseSchema.parse({ + planCode: 'pro', + issuedAt: '2024-01-01T00:00:00Z', + status: 'active', + })).toThrow(); + + expect(() => LicenseSchema.parse({ + tenantId: 'tenant123', + issuedAt: '2024-01-01T00:00:00Z', + status: 'active', + })).toThrow(); + + expect(() => LicenseSchema.parse({ + tenantId: 'tenant123', + planCode: 'pro', + status: 'active', + })).toThrow(); + }); + + it('should reject invalid status', () => { + expect(() => LicenseSchema.parse({ + tenantId: 'tenant123', + planCode: 'pro', + issuedAt: '2024-01-01T00:00:00Z', + status: 'inactive', + })).toThrow(); + }); +}); diff --git a/packages/spec/src/system/policy.test.ts b/packages/spec/src/system/policy.test.ts new file mode 100644 index 0000000..967e6f2 --- /dev/null +++ b/packages/spec/src/system/policy.test.ts @@ -0,0 +1,347 @@ +import { describe, it, expect } from 'vitest'; +import { + PolicySchema, + PasswordPolicySchema, + NetworkPolicySchema, + SessionPolicySchema, + AuditPolicySchema, + type Policy, +} from './policy.zod'; + +describe('PasswordPolicySchema', () => { + it('should accept valid minimal password policy', () => { + const policy = PasswordPolicySchema.parse({}); + + expect(policy.minLength).toBe(8); + expect(policy.requireUppercase).toBe(true); + expect(policy.requireLowercase).toBe(true); + expect(policy.requireNumbers).toBe(true); + expect(policy.requireSymbols).toBe(false); + expect(policy.historyCount).toBe(3); + }); + + it('should accept custom password policy', () => { + const policy = PasswordPolicySchema.parse({ + minLength: 12, + requireUppercase: true, + requireLowercase: true, + requireNumbers: true, + requireSymbols: true, + expirationDays: 90, + historyCount: 5, + }); + + expect(policy.minLength).toBe(12); + expect(policy.requireSymbols).toBe(true); + expect(policy.expirationDays).toBe(90); + }); + + it('should accept password expiration policy', () => { + const policy = PasswordPolicySchema.parse({ + expirationDays: 90, + }); + + expect(policy.expirationDays).toBe(90); + }); + + it('should accept password history policy', () => { + const policy = PasswordPolicySchema.parse({ + historyCount: 10, + }); + + expect(policy.historyCount).toBe(10); + }); + + it('should accept relaxed password policy', () => { + const policy = PasswordPolicySchema.parse({ + minLength: 6, + requireUppercase: false, + requireLowercase: false, + requireNumbers: false, + requireSymbols: false, + }); + + expect(policy.minLength).toBe(6); + expect(policy.requireUppercase).toBe(false); + }); +}); + +describe('NetworkPolicySchema', () => { + it('should accept valid network policy', () => { + const policy = NetworkPolicySchema.parse({ + trustedRanges: ['10.0.0.0/8', '192.168.0.0/16'], + }); + + expect(policy.trustedRanges).toEqual(['10.0.0.0/8', '192.168.0.0/16']); + expect(policy.blockUnknown).toBe(false); + expect(policy.vpnRequired).toBe(false); + }); + + it('should accept network policy with blocking', () => { + const policy = NetworkPolicySchema.parse({ + trustedRanges: ['10.0.0.0/8'], + blockUnknown: true, + }); + + expect(policy.blockUnknown).toBe(true); + }); + + it('should accept VPN requirement', () => { + const policy = NetworkPolicySchema.parse({ + trustedRanges: [], + vpnRequired: true, + }); + + expect(policy.vpnRequired).toBe(true); + }); + + it('should accept CIDR ranges', () => { + const policy = NetworkPolicySchema.parse({ + trustedRanges: ['10.0.0.0/8', '172.16.0.0/12', '192.168.0.0/16'], + }); + + expect(policy.trustedRanges).toHaveLength(3); + }); + + it('should accept specific IP addresses', () => { + const policy = NetworkPolicySchema.parse({ + trustedRanges: ['203.0.113.1/32', '198.51.100.42/32'], + }); + + expect(policy.trustedRanges).toContain('203.0.113.1/32'); + }); +}); + +describe('SessionPolicySchema', () => { + it('should accept valid minimal session policy', () => { + const policy = SessionPolicySchema.parse({}); + + expect(policy.idleTimeout).toBe(30); + expect(policy.absoluteTimeout).toBe(480); + expect(policy.forceMfa).toBe(false); + }); + + it('should accept custom idle timeout', () => { + const policy = SessionPolicySchema.parse({ + idleTimeout: 15, + }); + + expect(policy.idleTimeout).toBe(15); + }); + + it('should accept custom absolute timeout', () => { + const policy = SessionPolicySchema.parse({ + absoluteTimeout: 720, + }); + + expect(policy.absoluteTimeout).toBe(720); + }); + + it('should accept MFA requirement', () => { + const policy = SessionPolicySchema.parse({ + forceMfa: true, + }); + + expect(policy.forceMfa).toBe(true); + }); + + it('should accept strict session policy', () => { + const policy = SessionPolicySchema.parse({ + idleTimeout: 10, + absoluteTimeout: 60, + forceMfa: true, + }); + + expect(policy.idleTimeout).toBe(10); + expect(policy.absoluteTimeout).toBe(60); + expect(policy.forceMfa).toBe(true); + }); +}); + +describe('AuditPolicySchema', () => { + it('should accept valid minimal audit policy', () => { + const policy = AuditPolicySchema.parse({ + sensitiveFields: [], + }); + + expect(policy.logRetentionDays).toBe(180); + expect(policy.captureRead).toBe(false); + }); + + it('should accept custom retention period', () => { + const policy = AuditPolicySchema.parse({ + logRetentionDays: 365, + sensitiveFields: [], + }); + + expect(policy.logRetentionDays).toBe(365); + }); + + it('should accept sensitive field redaction', () => { + const policy = AuditPolicySchema.parse({ + sensitiveFields: ['password', 'ssn', 'credit_card'], + }); + + expect(policy.sensitiveFields).toEqual(['password', 'ssn', 'credit_card']); + }); + + it('should accept read capture policy', () => { + const policy = AuditPolicySchema.parse({ + sensitiveFields: [], + captureRead: true, + }); + + expect(policy.captureRead).toBe(true); + }); + + it('should accept compliance audit policy', () => { + const policy = AuditPolicySchema.parse({ + logRetentionDays: 2555, // 7 years for financial compliance + sensitiveFields: ['ssn', 'tax_id', 'bank_account', 'credit_card'], + captureRead: true, + }); + + expect(policy.logRetentionDays).toBe(2555); + expect(policy.sensitiveFields).toHaveLength(4); + }); +}); + +describe('PolicySchema', () => { + it('should accept valid minimal policy', () => { + const policy: Policy = { + name: 'default_policy', + }; + + expect(() => PolicySchema.parse(policy)).not.toThrow(); + }); + + it('should validate policy name format (snake_case)', () => { + expect(() => PolicySchema.parse({ + name: 'valid_policy_name', + })).not.toThrow(); + + expect(() => PolicySchema.parse({ + name: 'InvalidPolicy', + })).toThrow(); + + expect(() => PolicySchema.parse({ + name: 'invalid-policy', + })).toThrow(); + }); + + it('should accept policy with all sub-policies', () => { + const policy = PolicySchema.parse({ + name: 'comprehensive_policy', + password: { + minLength: 12, + requireSymbols: true, + expirationDays: 90, + }, + network: { + trustedRanges: ['10.0.0.0/8'], + blockUnknown: true, + }, + session: { + idleTimeout: 15, + forceMfa: true, + }, + audit: { + logRetentionDays: 365, + sensitiveFields: ['password', 'ssn'], + captureRead: false, + }, + }); + + expect(policy.password?.minLength).toBe(12); + expect(policy.network?.blockUnknown).toBe(true); + expect(policy.session?.forceMfa).toBe(true); + expect(policy.audit?.logRetentionDays).toBe(365); + }); + + it('should accept default policy flag', () => { + const policy = PolicySchema.parse({ + name: 'default_policy', + isDefault: true, + }); + + expect(policy.isDefault).toBe(true); + }); + + it('should accept profile assignments', () => { + const policy = PolicySchema.parse({ + name: 'admin_policy', + assignedProfiles: ['admin', 'super_admin'], + }); + + expect(policy.assignedProfiles).toEqual(['admin', 'super_admin']); + }); + + it('should handle enterprise security policy', () => { + const policy = PolicySchema.parse({ + name: 'enterprise_security', + password: { + minLength: 14, + requireUppercase: true, + requireLowercase: true, + requireNumbers: true, + requireSymbols: true, + expirationDays: 60, + historyCount: 10, + }, + network: { + trustedRanges: ['10.0.0.0/8'], + blockUnknown: true, + vpnRequired: true, + }, + session: { + idleTimeout: 10, + absoluteTimeout: 480, + forceMfa: true, + }, + audit: { + logRetentionDays: 2555, + sensitiveFields: ['password', 'ssn', 'tax_id', 'credit_card'], + captureRead: true, + }, + isDefault: false, + assignedProfiles: ['admin', 'finance'], + }); + + expect(policy.password?.minLength).toBe(14); + expect(policy.network?.vpnRequired).toBe(true); + expect(policy.session?.forceMfa).toBe(true); + expect(policy.audit?.captureRead).toBe(true); + }); + + it('should handle development policy', () => { + const policy = PolicySchema.parse({ + name: 'dev_policy', + password: { + minLength: 6, + requireUppercase: false, + requireSymbols: false, + }, + session: { + idleTimeout: 120, + forceMfa: false, + }, + isDefault: true, + }); + + expect(policy.password?.minLength).toBe(6); + expect(policy.session?.forceMfa).toBe(false); + expect(policy.isDefault).toBe(true); + }); + + it('should reject policy without required fields', () => { + expect(() => PolicySchema.parse({})).toThrow(); + }); + + it('should apply default values for isDefault', () => { + const policy = PolicySchema.parse({ + name: 'test_policy', + }); + + expect(policy.isDefault).toBe(false); + }); +}); diff --git a/packages/spec/src/system/territory.test.ts b/packages/spec/src/system/territory.test.ts new file mode 100644 index 0000000..e695f7a --- /dev/null +++ b/packages/spec/src/system/territory.test.ts @@ -0,0 +1,309 @@ +import { describe, it, expect } from 'vitest'; +import { + TerritorySchema, + TerritoryModelSchema, + TerritoryType, + type Territory, + type TerritoryModel, +} from './territory.zod'; + +describe('TerritoryType', () => { + it('should accept valid territory types', () => { + const validTypes = ['geography', 'industry', 'named_account', 'product_line']; + + validTypes.forEach(type => { + expect(() => TerritoryType.parse(type)).not.toThrow(); + }); + }); + + it('should reject invalid territory types', () => { + expect(() => TerritoryType.parse('customer')).toThrow(); + expect(() => TerritoryType.parse('region')).toThrow(); + expect(() => TerritoryType.parse('')).toThrow(); + }); +}); + +describe('TerritoryModelSchema', () => { + it('should accept valid minimal territory model', () => { + const model: TerritoryModel = { + name: 'FY2024 Planning', + }; + + expect(() => TerritoryModelSchema.parse(model)).not.toThrow(); + }); + + it('should apply default state', () => { + const model = TerritoryModelSchema.parse({ + name: 'FY2024', + }); + + expect(model.state).toBe('planning'); + }); + + it('should accept model with all fields', () => { + const model = TerritoryModelSchema.parse({ + name: 'FY2024 Planning', + state: 'active', + startDate: '2024-01-01', + endDate: '2024-12-31', + }); + + expect(model.state).toBe('active'); + expect(model.startDate).toBe('2024-01-01'); + expect(model.endDate).toBe('2024-12-31'); + }); + + it('should accept different states', () => { + const states: Array = ['planning', 'active', 'archived']; + + states.forEach(state => { + const model = TerritoryModelSchema.parse({ + name: 'Test Model', + state, + }); + expect(model.state).toBe(state); + }); + }); + + it('should reject invalid state', () => { + expect(() => TerritoryModelSchema.parse({ + name: 'Test Model', + state: 'inactive', + })).toThrow(); + }); + + it('should handle fiscal year planning model', () => { + const model = TerritoryModelSchema.parse({ + name: 'FY2024 Planning', + state: 'planning', + startDate: '2024-01-01', + endDate: '2024-12-31', + }); + + expect(model.name).toBe('FY2024 Planning'); + }); + + it('should handle active territory model', () => { + const model = TerritoryModelSchema.parse({ + name: 'Current Territory', + state: 'active', + }); + + expect(model.state).toBe('active'); + }); +}); + +describe('TerritorySchema', () => { + it('should accept valid minimal territory', () => { + const territory: Territory = { + name: 'west_coast', + label: 'West Coast', + modelId: 'fy2024', + }; + + expect(() => TerritorySchema.parse(territory)).not.toThrow(); + }); + + it('should validate territory name format (snake_case)', () => { + expect(() => TerritorySchema.parse({ + name: 'valid_territory_name', + label: 'Valid Territory', + modelId: 'model1', + })).not.toThrow(); + + expect(() => TerritorySchema.parse({ + name: 'InvalidTerritory', + label: 'Invalid', + modelId: 'model1', + })).toThrow(); + + expect(() => TerritorySchema.parse({ + name: 'invalid-territory', + label: 'Invalid', + modelId: 'model1', + })).toThrow(); + }); + + it('should apply default values', () => { + const territory = TerritorySchema.parse({ + name: 'test_territory', + label: 'Test Territory', + modelId: 'model1', + }); + + expect(territory.type).toBe('geography'); + expect(territory.accountAccess).toBe('read'); + expect(territory.opportunityAccess).toBe('read'); + expect(territory.caseAccess).toBe('read'); + }); + + it('should accept parent territory', () => { + const territory = TerritorySchema.parse({ + name: 'northern_california', + label: 'Northern California', + modelId: 'fy2024', + parent: 'west_coast', + }); + + expect(territory.parent).toBe('west_coast'); + }); + + it('should accept different territory types', () => { + const types: Array = ['geography', 'industry', 'named_account', 'product_line']; + + types.forEach(type => { + const territory = TerritorySchema.parse({ + name: 'test_territory', + label: 'Test', + modelId: 'model1', + type, + }); + expect(territory.type).toBe(type); + }); + }); + + it('should accept assignment rule', () => { + const territory = TerritorySchema.parse({ + name: 'california', + label: 'California', + modelId: 'fy2024', + assignmentRule: "BillingCountry = 'US' AND BillingState = 'CA'", + }); + + expect(territory.assignmentRule).toBe("BillingCountry = 'US' AND BillingState = 'CA'"); + }); + + it('should accept assigned users', () => { + const territory = TerritorySchema.parse({ + name: 'west_coast', + label: 'West Coast', + modelId: 'fy2024', + assignedUsers: ['user1', 'user2', 'user3'], + }); + + expect(territory.assignedUsers).toEqual(['user1', 'user2', 'user3']); + }); + + it('should accept access levels', () => { + const territory = TerritorySchema.parse({ + name: 'emea', + label: 'EMEA', + modelId: 'fy2024', + accountAccess: 'edit', + opportunityAccess: 'edit', + caseAccess: 'read', + }); + + expect(territory.accountAccess).toBe('edit'); + expect(territory.opportunityAccess).toBe('edit'); + expect(territory.caseAccess).toBe('read'); + }); + + it('should reject invalid access levels', () => { + expect(() => TerritorySchema.parse({ + name: 'test', + label: 'Test', + modelId: 'model1', + accountAccess: 'write', + })).toThrow(); + }); + + it('should handle geographic territory', () => { + const territory = TerritorySchema.parse({ + name: 'north_america', + label: 'North America', + modelId: 'fy2024', + type: 'geography', + assignmentRule: "BillingCountry IN ('US', 'CA', 'MX')", + assignedUsers: ['sales_manager_1'], + }); + + expect(territory.type).toBe('geography'); + }); + + it('should handle industry vertical territory', () => { + const territory = TerritorySchema.parse({ + name: 'healthcare', + label: 'Healthcare', + modelId: 'fy2024', + type: 'industry', + assignmentRule: "Industry = 'Healthcare'", + assignedUsers: ['industry_specialist_1', 'industry_specialist_2'], + }); + + expect(territory.type).toBe('industry'); + }); + + it('should handle named account territory', () => { + const territory = TerritorySchema.parse({ + name: 'strategic_accounts', + label: 'Strategic Accounts', + modelId: 'fy2024', + type: 'named_account', + assignmentRule: "AccountType = 'Strategic'", + assignedUsers: ['strategic_account_manager'], + accountAccess: 'edit', + opportunityAccess: 'edit', + }); + + expect(territory.type).toBe('named_account'); + expect(territory.accountAccess).toBe('edit'); + }); + + it('should handle product line territory', () => { + const territory = TerritorySchema.parse({ + name: 'cloud_products', + label: 'Cloud Products', + modelId: 'fy2024', + type: 'product_line', + assignedUsers: ['product_specialist_1'], + }); + + expect(territory.type).toBe('product_line'); + }); + + it('should handle hierarchical territories', () => { + const parent = TerritorySchema.parse({ + name: 'americas', + label: 'Americas', + modelId: 'fy2024', + }); + + const child = TerritorySchema.parse({ + name: 'north_america', + label: 'North America', + modelId: 'fy2024', + parent: 'americas', + }); + + expect(child.parent).toBe('americas'); + }); + + it('should handle territory with multiple users', () => { + const territory = TerritorySchema.parse({ + name: 'enterprise_accounts', + label: 'Enterprise Accounts', + modelId: 'fy2024', + assignedUsers: ['rep1', 'rep2', 'rep3', 'manager1'], + }); + + expect(territory.assignedUsers).toHaveLength(4); + }); + + it('should reject territory without required fields', () => { + expect(() => TerritorySchema.parse({ + label: 'Test', + modelId: 'model1', + })).toThrow(); + + expect(() => TerritorySchema.parse({ + name: 'test', + modelId: 'model1', + })).toThrow(); + + expect(() => TerritorySchema.parse({ + name: 'test', + label: 'Test', + })).toThrow(); + }); +}); diff --git a/packages/spec/src/system/translation.test.ts b/packages/spec/src/system/translation.test.ts new file mode 100644 index 0000000..d83bd87 --- /dev/null +++ b/packages/spec/src/system/translation.test.ts @@ -0,0 +1,392 @@ +import { describe, it, expect } from 'vitest'; +import { + TranslationDataSchema, + TranslationBundleSchema, + LocaleSchema, + type TranslationBundle, +} from './translation.zod'; + +describe('LocaleSchema', () => { + it('should accept valid locale strings', () => { + const validLocales = ['en-US', 'zh-CN', 'es-ES', 'fr-FR', 'de-DE', 'ja-JP']; + + validLocales.forEach(locale => { + expect(() => LocaleSchema.parse(locale)).not.toThrow(); + }); + }); + + it('should accept simple language codes', () => { + const locales = ['en', 'zh', 'es', 'fr', 'de']; + + locales.forEach(locale => { + expect(() => LocaleSchema.parse(locale)).not.toThrow(); + }); + }); +}); + +describe('TranslationDataSchema', () => { + it('should accept empty translation data', () => { + const data = TranslationDataSchema.parse({}); + + expect(data).toBeDefined(); + }); + + it('should accept object translations', () => { + const data = TranslationDataSchema.parse({ + objects: { + account: { + label: 'Account', + pluralLabel: 'Accounts', + }, + }, + }); + + expect(data.objects?.account.label).toBe('Account'); + }); + + it('should accept field translations', () => { + const data = TranslationDataSchema.parse({ + objects: { + account: { + label: 'Account', + fields: { + name: { + label: 'Account Name', + help: 'Enter the name of the account', + }, + status: { + label: 'Status', + options: { + active: 'Active', + inactive: 'Inactive', + }, + }, + }, + }, + }, + }); + + expect(data.objects?.account.fields?.name.label).toBe('Account Name'); + expect(data.objects?.account.fields?.status.options?.active).toBe('Active'); + }); + + it('should accept app translations', () => { + const data = TranslationDataSchema.parse({ + apps: { + sales: { + label: 'Sales', + description: 'Manage your sales pipeline', + }, + }, + }); + + expect(data.apps?.sales.label).toBe('Sales'); + }); + + it('should accept message translations', () => { + const data = TranslationDataSchema.parse({ + messages: { + 'common.save': 'Save', + 'common.cancel': 'Cancel', + 'error.required': 'This field is required', + }, + }); + + expect(data.messages?.['common.save']).toBe('Save'); + }); + + it('should accept complete translation data', () => { + const data = TranslationDataSchema.parse({ + objects: { + account: { + label: 'Account', + pluralLabel: 'Accounts', + fields: { + name: { + label: 'Name', + }, + }, + }, + }, + apps: { + sales: { + label: 'Sales', + }, + }, + messages: { + 'common.save': 'Save', + }, + }); + + expect(data.objects).toBeDefined(); + expect(data.apps).toBeDefined(); + expect(data.messages).toBeDefined(); + }); +}); + +describe('TranslationBundleSchema', () => { + it('should accept valid translation bundle', () => { + const bundle: TranslationBundle = { + 'en-US': { + objects: { + account: { + label: 'Account', + pluralLabel: 'Accounts', + }, + }, + }, + }; + + expect(() => TranslationBundleSchema.parse(bundle)).not.toThrow(); + }); + + it('should accept multi-language bundle', () => { + const bundle = TranslationBundleSchema.parse({ + 'en-US': { + objects: { + account: { + label: 'Account', + }, + }, + messages: { + 'common.save': 'Save', + }, + }, + 'zh-CN': { + objects: { + account: { + label: '客户', + }, + }, + messages: { + 'common.save': '保存', + }, + }, + }); + + expect(bundle['en-US'].objects?.account.label).toBe('Account'); + expect(bundle['zh-CN'].objects?.account.label).toBe('客户'); + }); + + it('should handle English translations', () => { + const bundle = TranslationBundleSchema.parse({ + 'en-US': { + objects: { + account: { + label: 'Account', + pluralLabel: 'Accounts', + fields: { + name: { + label: 'Account Name', + help: 'The name of the account', + }, + type: { + label: 'Type', + options: { + customer: 'Customer', + partner: 'Partner', + vendor: 'Vendor', + }, + }, + }, + }, + }, + apps: { + sales: { + label: 'Sales', + description: 'Manage your sales pipeline', + }, + }, + messages: { + 'common.save': 'Save', + 'common.cancel': 'Cancel', + 'common.delete': 'Delete', + }, + }, + }); + + expect(bundle['en-US'].objects?.account.label).toBe('Account'); + }); + + it('should handle Chinese translations', () => { + const bundle = TranslationBundleSchema.parse({ + 'zh-CN': { + objects: { + account: { + label: '客户', + pluralLabel: '客户', + fields: { + name: { + label: '客户名称', + help: '输入客户名称', + }, + }, + }, + }, + messages: { + 'common.save': '保存', + 'common.cancel': '取消', + }, + }, + }); + + expect(bundle['zh-CN'].objects?.account.label).toBe('客户'); + }); + + it('should handle Spanish translations', () => { + const bundle = TranslationBundleSchema.parse({ + 'es-ES': { + objects: { + account: { + label: 'Cuenta', + pluralLabel: 'Cuentas', + }, + }, + messages: { + 'common.save': 'Guardar', + 'common.cancel': 'Cancelar', + }, + }, + }); + + expect(bundle['es-ES'].objects?.account.label).toBe('Cuenta'); + }); + + it('should handle field option translations', () => { + const bundle = TranslationBundleSchema.parse({ + 'en-US': { + objects: { + opportunity: { + label: 'Opportunity', + fields: { + stage: { + label: 'Stage', + options: { + prospecting: 'Prospecting', + qualification: 'Qualification', + proposal: 'Proposal', + closed_won: 'Closed Won', + closed_lost: 'Closed Lost', + }, + }, + }, + }, + }, + }, + 'zh-CN': { + objects: { + opportunity: { + label: '商机', + fields: { + stage: { + label: '阶段', + options: { + prospecting: '寻找客户', + qualification: '资格审查', + proposal: '提案', + closed_won: '成交', + closed_lost: '失败', + }, + }, + }, + }, + }, + }, + }); + + expect(bundle['en-US'].objects?.opportunity.fields?.stage.options?.prospecting).toBe('Prospecting'); + expect(bundle['zh-CN'].objects?.opportunity.fields?.stage.options?.prospecting).toBe('寻找客户'); + }); + + it('should handle app menu translations', () => { + const bundle = TranslationBundleSchema.parse({ + 'en-US': { + apps: { + sales: { + label: 'Sales', + description: 'Manage your sales pipeline and opportunities', + }, + service: { + label: 'Service', + description: 'Handle customer support cases', + }, + }, + }, + 'fr-FR': { + apps: { + sales: { + label: 'Ventes', + description: 'Gérez votre pipeline de ventes', + }, + service: { + label: 'Service', + description: 'Gérez les cas de support client', + }, + }, + }, + }); + + expect(bundle['en-US'].apps?.sales.label).toBe('Sales'); + expect(bundle['fr-FR'].apps?.sales.label).toBe('Ventes'); + }); + + it('should handle UI message translations', () => { + const bundle = TranslationBundleSchema.parse({ + 'en-US': { + messages: { + 'error.required': 'This field is required', + 'error.invalid_email': 'Invalid email address', + 'success.saved': 'Successfully saved', + 'confirm.delete': 'Are you sure you want to delete this record?', + }, + }, + 'de-DE': { + messages: { + 'error.required': 'Dieses Feld ist erforderlich', + 'error.invalid_email': 'Ungültige E-Mail-Adresse', + 'success.saved': 'Erfolgreich gespeichert', + 'confirm.delete': 'Möchten Sie diesen Datensatz wirklich löschen?', + }, + }, + }); + + expect(bundle['en-US'].messages?.['error.required']).toBe('This field is required'); + expect(bundle['de-DE'].messages?.['error.required']).toBe('Dieses Feld ist erforderlich'); + }); + + it('should accept empty locale data', () => { + const bundle = TranslationBundleSchema.parse({ + 'en-US': {}, + 'zh-CN': {}, + }); + + expect(bundle['en-US']).toBeDefined(); + expect(bundle['zh-CN']).toBeDefined(); + }); + + it('should handle partial translations', () => { + const bundle = TranslationBundleSchema.parse({ + 'en-US': { + objects: { + account: { + label: 'Account', + }, + }, + messages: { + 'common.save': 'Save', + }, + }, + 'zh-CN': { + objects: { + account: { + label: '客户', + }, + }, + // messages not translated yet + }, + }); + + expect(bundle['zh-CN'].objects?.account.label).toBe('客户'); + expect(bundle['zh-CN'].messages).toBeUndefined(); + }); +}); diff --git a/packages/spec/src/system/webhook.test.ts b/packages/spec/src/system/webhook.test.ts new file mode 100644 index 0000000..89c684d --- /dev/null +++ b/packages/spec/src/system/webhook.test.ts @@ -0,0 +1,447 @@ +import { describe, it, expect } from 'vitest'; +import { + WebhookSchema, + WebhookReceiverSchema, + WebhookTriggerType, + type Webhook, + type WebhookReceiver, +} from './webhook.zod'; + +describe('WebhookTriggerType', () => { + it('should accept valid trigger types', () => { + const validTypes = ['create', 'update', 'delete', 'undelete', 'api']; + + validTypes.forEach(type => { + expect(() => WebhookTriggerType.parse(type)).not.toThrow(); + }); + }); + + it('should reject invalid trigger types', () => { + expect(() => WebhookTriggerType.parse('insert')).toThrow(); + expect(() => WebhookTriggerType.parse('modify')).toThrow(); + expect(() => WebhookTriggerType.parse('')).toThrow(); + }); +}); + +describe('WebhookSchema', () => { + it('should accept valid minimal webhook', () => { + const webhook: Webhook = { + name: 'account_webhook', + object: 'account', + triggers: ['create', 'update'], + url: 'https://example.com/webhook', + }; + + expect(() => WebhookSchema.parse(webhook)).not.toThrow(); + }); + + it('should validate webhook name format (snake_case)', () => { + expect(() => WebhookSchema.parse({ + name: 'valid_webhook_name', + object: 'account', + triggers: ['create'], + url: 'https://example.com/webhook', + })).not.toThrow(); + + expect(() => WebhookSchema.parse({ + name: 'InvalidWebhook', + object: 'account', + triggers: ['create'], + url: 'https://example.com/webhook', + })).toThrow(); + + expect(() => WebhookSchema.parse({ + name: 'invalid-webhook', + object: 'account', + triggers: ['create'], + url: 'https://example.com/webhook', + })).toThrow(); + }); + + it('should apply default values', () => { + const webhook = WebhookSchema.parse({ + name: 'test_webhook', + object: 'account', + triggers: ['create'], + url: 'https://example.com/webhook', + }); + + expect(webhook.method).toBe('POST'); + expect(webhook.includeSession).toBe(false); + expect(webhook.retryCount).toBe(3); + expect(webhook.isActive).toBe(true); + }); + + it('should accept webhook with all fields', () => { + const webhook = WebhookSchema.parse({ + name: 'full_webhook', + label: 'Full Webhook', + object: 'contact', + triggers: ['create', 'update', 'delete'], + url: 'https://example.com/webhook', + method: 'POST', + secret: 'secret_key_123', + headers: { + 'Authorization': 'Bearer token123', + 'X-Custom-Header': 'value', + }, + payloadFields: ['name', 'email', 'phone'], + includeSession: true, + retryCount: 5, + isActive: true, + }); + + expect(webhook.label).toBe('Full Webhook'); + expect(webhook.triggers).toHaveLength(3); + expect(webhook.secret).toBe('secret_key_123'); + }); + + it('should accept different HTTP methods', () => { + const methods: Array = ['POST', 'PUT', 'GET']; + + methods.forEach(method => { + const webhook = WebhookSchema.parse({ + name: 'test_webhook', + object: 'account', + triggers: ['create'], + url: 'https://example.com/webhook', + method, + }); + expect(webhook.method).toBe(method); + }); + }); + + it('should reject invalid HTTP method', () => { + expect(() => WebhookSchema.parse({ + name: 'test_webhook', + object: 'account', + triggers: ['create'], + url: 'https://example.com/webhook', + method: 'DELETE', + })).toThrow(); + }); + + it('should accept multiple triggers', () => { + const webhook = WebhookSchema.parse({ + name: 'multi_trigger_webhook', + object: 'account', + triggers: ['create', 'update', 'delete', 'undelete'], + url: 'https://example.com/webhook', + }); + + expect(webhook.triggers).toHaveLength(4); + }); + + it('should accept HMAC secret for signing', () => { + const webhook = WebhookSchema.parse({ + name: 'secure_webhook', + object: 'account', + triggers: ['create'], + url: 'https://example.com/webhook', + secret: 'hmac_secret_key', + }); + + expect(webhook.secret).toBe('hmac_secret_key'); + }); + + it('should accept custom headers', () => { + const webhook = WebhookSchema.parse({ + name: 'auth_webhook', + object: 'account', + triggers: ['create'], + url: 'https://example.com/webhook', + headers: { + 'Authorization': 'Bearer token', + 'X-API-Key': 'api_key_123', + }, + }); + + expect(webhook.headers).toHaveProperty('Authorization'); + expect(webhook.headers).toHaveProperty('X-API-Key'); + }); + + it('should accept payload field filtering', () => { + const webhook = WebhookSchema.parse({ + name: 'filtered_webhook', + object: 'contact', + triggers: ['create'], + url: 'https://example.com/webhook', + payloadFields: ['email', 'name'], + }); + + expect(webhook.payloadFields).toEqual(['email', 'name']); + }); + + it('should accept session inclusion', () => { + const webhook = WebhookSchema.parse({ + name: 'session_webhook', + object: 'account', + triggers: ['create'], + url: 'https://example.com/webhook', + includeSession: true, + }); + + expect(webhook.includeSession).toBe(true); + }); + + it('should accept custom retry count', () => { + const webhook = WebhookSchema.parse({ + name: 'retry_webhook', + object: 'account', + triggers: ['create'], + url: 'https://example.com/webhook', + retryCount: 10, + }); + + expect(webhook.retryCount).toBe(10); + }); + + it('should accept inactive webhook', () => { + const webhook = WebhookSchema.parse({ + name: 'inactive_webhook', + object: 'account', + triggers: ['create'], + url: 'https://example.com/webhook', + isActive: false, + }); + + expect(webhook.isActive).toBe(false); + }); + + it('should validate URL format', () => { + expect(() => WebhookSchema.parse({ + name: 'test_webhook', + object: 'account', + triggers: ['create'], + url: 'not-a-url', + })).toThrow(); + + expect(() => WebhookSchema.parse({ + name: 'test_webhook', + object: 'account', + triggers: ['create'], + url: 'https://example.com/webhook', + })).not.toThrow(); + }); + + it('should handle Slack webhook', () => { + const webhook = WebhookSchema.parse({ + name: 'slack_notification', + label: 'Slack Notification', + object: 'opportunity', + triggers: ['create'], + url: 'https://hooks.slack.com/services/T00000000/B00000000/XXXXXXXXXXXXXXXXXXXX', + method: 'POST', + payloadFields: ['name', 'amount', 'stage'], + }); + + expect(webhook.url).toContain('slack.com'); + }); + + it('should handle Stripe webhook', () => { + const webhook = WebhookSchema.parse({ + name: 'stripe_payment', + object: 'payment', + triggers: ['create', 'update'], + url: 'https://example.com/stripe/webhook', + secret: 'whsec_stripe_signing_secret', + retryCount: 5, + }); + + expect(webhook.secret).toContain('whsec'); + }); + + it('should reject webhook without required fields', () => { + expect(() => WebhookSchema.parse({ + object: 'account', + triggers: ['create'], + url: 'https://example.com/webhook', + })).toThrow(); + + expect(() => WebhookSchema.parse({ + name: 'test_webhook', + triggers: ['create'], + url: 'https://example.com/webhook', + })).toThrow(); + + expect(() => WebhookSchema.parse({ + name: 'test_webhook', + object: 'account', + url: 'https://example.com/webhook', + })).toThrow(); + }); +}); + +describe('WebhookReceiverSchema', () => { + it('should accept valid minimal webhook receiver', () => { + const receiver: WebhookReceiver = { + name: 'stripe_receiver', + path: '/webhooks/stripe', + target: 'stripe_flow', + }; + + expect(() => WebhookReceiverSchema.parse(receiver)).not.toThrow(); + }); + + it('should validate receiver name format (snake_case)', () => { + expect(() => WebhookReceiverSchema.parse({ + name: 'valid_receiver_name', + path: '/webhooks/test', + target: 'flow_id', + })).not.toThrow(); + + expect(() => WebhookReceiverSchema.parse({ + name: 'InvalidReceiver', + path: '/webhooks/test', + target: 'flow_id', + })).toThrow(); + }); + + it('should apply default values', () => { + const receiver = WebhookReceiverSchema.parse({ + name: 'test_receiver', + path: '/webhooks/test', + target: 'flow_id', + }); + + expect(receiver.verificationType).toBe('none'); + expect(receiver.action).toBe('trigger_flow'); + }); + + it('should accept receiver with all fields', () => { + const receiver = WebhookReceiverSchema.parse({ + name: 'secure_receiver', + path: '/webhooks/secure', + verificationType: 'hmac', + verificationParams: { + header: 'X-Hub-Signature', + secret: 'secret_key', + }, + action: 'trigger_flow', + target: 'processing_flow', + }); + + expect(receiver.verificationType).toBe('hmac'); + expect(receiver.verificationParams?.secret).toBe('secret_key'); + }); + + it('should accept different verification types', () => { + const types: Array = ['none', 'header_token', 'hmac', 'ip_whitelist']; + + types.forEach(type => { + const receiver = WebhookReceiverSchema.parse({ + name: 'test_receiver', + path: '/webhooks/test', + verificationType: type, + target: 'flow_id', + }); + expect(receiver.verificationType).toBe(type); + }); + }); + + it('should accept different action types', () => { + const actions: Array = ['trigger_flow', 'script', 'upsert_record']; + + actions.forEach(action => { + const receiver = WebhookReceiverSchema.parse({ + name: 'test_receiver', + path: '/webhooks/test', + action, + target: 'target_id', + }); + expect(receiver.action).toBe(action); + }); + }); + + it('should accept header token verification', () => { + const receiver = WebhookReceiverSchema.parse({ + name: 'header_auth_receiver', + path: '/webhooks/auth', + verificationType: 'header_token', + verificationParams: { + header: 'X-API-Token', + secret: 'expected_token_value', + }, + target: 'flow_id', + }); + + expect(receiver.verificationType).toBe('header_token'); + }); + + it('should accept HMAC verification', () => { + const receiver = WebhookReceiverSchema.parse({ + name: 'hmac_receiver', + path: '/webhooks/hmac', + verificationType: 'hmac', + verificationParams: { + header: 'X-Hub-Signature-256', + secret: 'hmac_secret', + }, + target: 'flow_id', + }); + + expect(receiver.verificationType).toBe('hmac'); + }); + + it('should accept IP whitelist verification', () => { + const receiver = WebhookReceiverSchema.parse({ + name: 'ip_receiver', + path: '/webhooks/ip', + verificationType: 'ip_whitelist', + verificationParams: { + ips: ['192.168.1.1', '10.0.0.0/8'], + }, + target: 'flow_id', + }); + + expect(receiver.verificationParams?.ips).toHaveLength(2); + }); + + it('should handle GitHub webhook receiver', () => { + const receiver = WebhookReceiverSchema.parse({ + name: 'github_webhook', + path: '/webhooks/github', + verificationType: 'hmac', + verificationParams: { + header: 'X-Hub-Signature-256', + secret: 'github_webhook_secret', + }, + action: 'trigger_flow', + target: 'github_flow', + }); + + expect(receiver.path).toBe('/webhooks/github'); + }); + + it('should handle Stripe webhook receiver', () => { + const receiver = WebhookReceiverSchema.parse({ + name: 'stripe_webhook', + path: '/webhooks/stripe', + verificationType: 'hmac', + verificationParams: { + secret: 'whsec_stripe_signing_secret', + }, + action: 'upsert_record', + target: 'payment_object', + }); + + expect(receiver.action).toBe('upsert_record'); + }); + + it('should reject receiver without required fields', () => { + expect(() => WebhookReceiverSchema.parse({ + path: '/webhooks/test', + target: 'flow_id', + })).toThrow(); + + expect(() => WebhookReceiverSchema.parse({ + name: 'test_receiver', + target: 'flow_id', + })).toThrow(); + + expect(() => WebhookReceiverSchema.parse({ + name: 'test_receiver', + path: '/webhooks/test', + })).toThrow(); + }); +}); From b01f98d6cc7502512a4114ef171d8ed4b300b210 Mon Sep 17 00:00:00 2001 From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com> Date: Thu, 22 Jan 2026 04:53:11 +0000 Subject: [PATCH 4/6] Changes before error encountered Co-authored-by: huangyiirene <7665279+huangyiirene@users.noreply.github.com> --- packages/spec/src/data/filter.test.ts | 133 ++++++++++++++++++++++++++ 1 file changed, 133 insertions(+) diff --git a/packages/spec/src/data/filter.test.ts b/packages/spec/src/data/filter.test.ts index 57fab6c..c72e127 100644 --- a/packages/spec/src/data/filter.test.ts +++ b/packages/spec/src/data/filter.test.ts @@ -9,6 +9,7 @@ import { RangeOperatorSchema, StringOperatorSchema, SpecialOperatorSchema, + NormalizedFilterSchema, FILTER_OPERATORS, LOGICAL_OPERATORS, ALL_OPERATORS, @@ -620,3 +621,135 @@ describe('Real-World Use Cases', () => { expect(() => QueryFilterSchema.parse(filter)).not.toThrow(); }); }); + +describe('NormalizedFilterSchema', () => { + it('should accept normalized $and condition', () => { + const filter = { + $and: [ + { age: { $eq: 18 } }, + { role: { $eq: 'admin' } } + ] + }; + + expect(() => NormalizedFilterSchema.parse(filter)).not.toThrow(); + }); + + it('should accept normalized $or condition', () => { + const filter = { + $or: [ + { status: { $eq: 'active' } }, + { status: { $eq: 'pending' } } + ] + }; + + expect(() => NormalizedFilterSchema.parse(filter)).not.toThrow(); + }); + + it('should accept normalized $not condition', () => { + const filter = { + $not: { deleted: { $eq: true } } + }; + + expect(() => NormalizedFilterSchema.parse(filter)).not.toThrow(); + }); + + it('should accept nested normalized filters in $and', () => { + const filter = { + $and: [ + { age: { $gte: 18 } }, + { + $or: [ + { role: { $eq: 'admin' } }, + { role: { $eq: 'moderator' } } + ] + } + ] + }; + + expect(() => NormalizedFilterSchema.parse(filter)).not.toThrow(); + }); + + it('should accept nested normalized filters in $or', () => { + const filter = { + $or: [ + { status: { $eq: 'active' } }, + { + $and: [ + { status: { $eq: 'pending' } }, + { verified: { $eq: true } } + ] + } + ] + }; + + expect(() => NormalizedFilterSchema.parse(filter)).not.toThrow(); + }); + + it('should accept nested normalized filter in $not', () => { + const filter = { + $not: { + $and: [ + { deleted: { $eq: true } }, + { archived: { $eq: true } } + ] + } + }; + + expect(() => NormalizedFilterSchema.parse(filter)).not.toThrow(); + }); + + it('should accept complex deeply nested normalized filters', () => { + const filter = { + $and: [ + { active: { $eq: true } }, + { + $or: [ + { type: { $eq: 'premium' } }, + { + $and: [ + { type: { $eq: 'basic' } }, + { credits: { $gte: 100 } } + ] + } + ] + } + ] + }; + + expect(() => NormalizedFilterSchema.parse(filter)).not.toThrow(); + }); + + it('should accept multiple operators in $and', () => { + const filter = { + $and: [ + { age: { $gte: 18 } }, + { age: { $lte: 65 } }, + { role: { $in: ['user', 'admin'] } } + ] + }; + + expect(() => NormalizedFilterSchema.parse(filter)).not.toThrow(); + }); + + it('should accept empty optional operators', () => { + const filter = {}; + expect(() => NormalizedFilterSchema.parse(filter)).not.toThrow(); + }); + + it('should accept combination of all logical operators', () => { + const filter = { + $and: [ + { active: { $eq: true } } + ], + $or: [ + { role: { $eq: 'admin' } }, + { role: { $eq: 'moderator' } } + ], + $not: { + deleted: { $eq: true } + } + }; + + expect(() => NormalizedFilterSchema.parse(filter)).not.toThrow(); + }); +}); From 9e8678f75d907c646ee0007519aac9de191d0c44 Mon Sep 17 00:00:00 2001 From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com> Date: Thu, 22 Jan 2026 05:16:44 +0000 Subject: [PATCH 5/6] Add comprehensive tests for medium-coverage files: filter, sharing, report, page, api, datasource, contract Co-authored-by: hotlong <50353452+hotlong@users.noreply.github.com> --- packages/spec/src/api/contract.test.ts | 444 ++++++++++++++++++++ packages/spec/src/data/sharing.test.ts | 293 +++++++++++++ packages/spec/src/system/api.test.ts | 441 +++++++++++++++++++ packages/spec/src/system/datasource.test.ts | 426 +++++++++++++++++++ packages/spec/src/ui/page.test.ts | 385 +++++++++++++++++ packages/spec/src/ui/report.test.ts | 427 +++++++++++++++++++ 6 files changed, 2416 insertions(+) create mode 100644 packages/spec/src/api/contract.test.ts create mode 100644 packages/spec/src/data/sharing.test.ts create mode 100644 packages/spec/src/system/api.test.ts create mode 100644 packages/spec/src/system/datasource.test.ts create mode 100644 packages/spec/src/ui/page.test.ts create mode 100644 packages/spec/src/ui/report.test.ts diff --git a/packages/spec/src/api/contract.test.ts b/packages/spec/src/api/contract.test.ts new file mode 100644 index 0000000..29fcb7e --- /dev/null +++ b/packages/spec/src/api/contract.test.ts @@ -0,0 +1,444 @@ +import { describe, it, expect } from 'vitest'; +import { + ApiErrorSchema, + BaseResponseSchema, + CreateRequestSchema, + UpdateRequestSchema, + BulkRequestSchema, + ExportRequestSchema, + SingleRecordResponseSchema, + ListRecordResponseSchema, + ModificationResultSchema, + BulkResponseSchema, + DeleteResponseSchema, + RecordDataSchema, + ApiContracts, +} from './contract.zod'; + +describe('ApiErrorSchema', () => { + it('should accept valid API error', () => { + const error = ApiErrorSchema.parse({ + code: 'validation_error', + message: 'Invalid input data', + }); + + expect(error.code).toBe('validation_error'); + expect(error.message).toBe('Invalid input data'); + }); + + it('should accept error with details', () => { + const error = ApiErrorSchema.parse({ + code: 'validation_error', + message: 'Validation failed', + details: { + fields: { + email: 'Invalid email format', + age: 'Must be a positive number', + }, + }, + }); + + expect(error.details).toBeDefined(); + }); +}); + +describe('BaseResponseSchema', () => { + it('should accept success response', () => { + const response = BaseResponseSchema.parse({ + success: true, + }); + + expect(response.success).toBe(true); + }); + + it('should accept error response', () => { + const response = BaseResponseSchema.parse({ + success: false, + error: { + code: 'not_found', + message: 'Record not found', + }, + }); + + expect(response.success).toBe(false); + expect(response.error?.code).toBe('not_found'); + }); + + it('should accept response with metadata', () => { + const response = BaseResponseSchema.parse({ + success: true, + meta: { + timestamp: '2024-01-01T00:00:00Z', + duration: 150, + requestId: 'req_123', + traceId: 'trace_456', + }, + }); + + expect(response.meta?.duration).toBe(150); + expect(response.meta?.requestId).toBe('req_123'); + }); +}); + +describe('RecordDataSchema', () => { + it('should accept any key-value record', () => { + const record = RecordDataSchema.parse({ + name: 'John Doe', + email: 'john@example.com', + age: 30, + }); + + expect(record.name).toBe('John Doe'); + }); + + it('should accept nested objects', () => { + const record = RecordDataSchema.parse({ + user: { + name: 'John', + profile: { + bio: 'Developer', + }, + }, + }); + + expect(record.user).toBeDefined(); + }); +}); + +describe('CreateRequestSchema', () => { + it('should accept valid create request', () => { + const request = CreateRequestSchema.parse({ + data: { + name: 'New Account', + industry: 'Technology', + }, + }); + + expect(request.data.name).toBe('New Account'); + }); + + it('should accept create request with complex data', () => { + const request = CreateRequestSchema.parse({ + data: { + name: 'John Doe', + email: 'john@example.com', + address: { + street: '123 Main St', + city: 'New York', + }, + tags: ['customer', 'vip'], + }, + }); + + expect(request.data.address).toBeDefined(); + expect(request.data.tags).toHaveLength(2); + }); +}); + +describe('UpdateRequestSchema', () => { + it('should accept valid update request', () => { + const request = UpdateRequestSchema.parse({ + data: { + status: 'active', + updatedAt: new Date().toISOString(), + }, + }); + + expect(request.data.status).toBe('active'); + }); + + it('should accept partial update', () => { + const request = UpdateRequestSchema.parse({ + data: { + name: 'Updated Name', + }, + }); + + expect(request.data.name).toBe('Updated Name'); + }); +}); + +describe('BulkRequestSchema', () => { + it('should accept valid bulk request', () => { + const request = BulkRequestSchema.parse({ + records: [ + { name: 'Record 1' }, + { name: 'Record 2' }, + ], + }); + + expect(request.records).toHaveLength(2); + }); + + it('should apply default allOrNone', () => { + const request = BulkRequestSchema.parse({ + records: [{ name: 'Record 1' }], + }); + + expect(request.allOrNone).toBe(true); + }); + + it('should accept custom allOrNone', () => { + const request = BulkRequestSchema.parse({ + records: [{ name: 'Record 1' }], + allOrNone: false, + }); + + expect(request.allOrNone).toBe(false); + }); + + it('should accept bulk request with multiple records', () => { + const request = BulkRequestSchema.parse({ + records: Array.from({ length: 10 }, (_, i) => ({ + name: `Record ${i + 1}`, + index: i, + })), + }); + + expect(request.records).toHaveLength(10); + }); +}); + +describe('ExportRequestSchema', () => { + it('should accept export request with query', () => { + const request = ExportRequestSchema.parse({ + object: 'account', + fields: ['name', 'email'], + }); + + expect(request.object).toBe('account'); + }); + + it('should apply default format', () => { + const request = ExportRequestSchema.parse({ + object: 'account', + }); + + expect(request.format).toBe('csv'); + }); + + it('should accept different export formats', () => { + const formats = ['csv', 'json', 'xlsx']; + + formats.forEach(format => { + const request = ExportRequestSchema.parse({ + object: 'account', + format, + }); + expect(request.format).toBe(format); + }); + }); + + it('should accept export with filters', () => { + const request = ExportRequestSchema.parse({ + object: 'account', + fields: ['name', 'email'], + filters: ['status', '=', 'active'], + format: 'xlsx', + }); + + expect(request.format).toBe('xlsx'); + expect(request.filters).toBeDefined(); + }); +}); + +describe('SingleRecordResponseSchema', () => { + it('should accept successful single record response', () => { + const response = SingleRecordResponseSchema.parse({ + success: true, + data: { + id: '123', + name: 'John Doe', + email: 'john@example.com', + }, + }); + + expect(response.success).toBe(true); + expect(response.data.id).toBe('123'); + }); + + it('should accept error response', () => { + const response = SingleRecordResponseSchema.parse({ + success: false, + error: { + code: 'not_found', + message: 'Record not found', + }, + data: {}, + }); + + expect(response.success).toBe(false); + }); +}); + +describe('ListRecordResponseSchema', () => { + it('should accept list response', () => { + const response = ListRecordResponseSchema.parse({ + success: true, + data: [ + { id: '1', name: 'Record 1' }, + { id: '2', name: 'Record 2' }, + ], + pagination: { + total: 100, + limit: 10, + offset: 0, + hasMore: true, + }, + }); + + expect(response.data).toHaveLength(2); + expect(response.pagination.total).toBe(100); + }); + + it('should accept empty list response', () => { + const response = ListRecordResponseSchema.parse({ + success: true, + data: [], + pagination: { + total: 0, + limit: 10, + offset: 0, + hasMore: false, + }, + }); + + expect(response.data).toHaveLength(0); + expect(response.pagination.hasMore).toBe(false); + }); +}); + +describe('ModificationResultSchema', () => { + it('should accept successful modification result', () => { + const result = ModificationResultSchema.parse({ + id: '123', + success: true, + }); + + expect(result.success).toBe(true); + expect(result.id).toBe('123'); + }); + + it('should accept failed modification result', () => { + const result = ModificationResultSchema.parse({ + success: false, + errors: [ + { + code: 'validation_error', + message: 'Invalid data', + }, + ], + }); + + expect(result.success).toBe(false); + expect(result.errors).toHaveLength(1); + }); +}); + +describe('BulkResponseSchema', () => { + it('should accept successful bulk response', () => { + const response = BulkResponseSchema.parse({ + success: true, + data: [ + { id: '1', success: true }, + { id: '2', success: true }, + ], + }); + + expect(response.data).toHaveLength(2); + }); + + it('should accept partial success bulk response', () => { + const response = BulkResponseSchema.parse({ + success: false, + data: [ + { id: '1', success: true }, + { + success: false, + errors: [ + { code: 'validation_error', message: 'Invalid' }, + ], + }, + ], + }); + + expect(response.data).toHaveLength(2); + expect(response.data[1].success).toBe(false); + }); +}); + +describe('DeleteResponseSchema', () => { + it('should accept successful delete response', () => { + const response = DeleteResponseSchema.parse({ + success: true, + id: '123', + }); + + expect(response.success).toBe(true); + expect(response.id).toBe('123'); + }); + + it('should accept delete error response', () => { + const response = DeleteResponseSchema.parse({ + success: false, + id: '123', + error: { + code: 'not_found', + message: 'Record not found', + }, + }); + + expect(response.success).toBe(false); + }); +}); + +describe('ApiContracts', () => { + it('should have all standard CRUD contracts', () => { + expect(ApiContracts.create).toBeDefined(); + expect(ApiContracts.update).toBeDefined(); + expect(ApiContracts.delete).toBeDefined(); + expect(ApiContracts.get).toBeDefined(); + expect(ApiContracts.list).toBeDefined(); + }); + + it('should have bulk operation contracts', () => { + expect(ApiContracts.bulkCreate).toBeDefined(); + expect(ApiContracts.bulkUpdate).toBeDefined(); + expect(ApiContracts.bulkUpsert).toBeDefined(); + expect(ApiContracts.bulkDelete).toBeDefined(); + }); + + it('should validate create contract input', () => { + const input = ApiContracts.create.input.parse({ + data: { name: 'Test' }, + }); + + expect(input.data.name).toBe('Test'); + }); + + it('should validate create contract output', () => { + const output = ApiContracts.create.output.parse({ + success: true, + data: { id: '123', name: 'Test' }, + }); + + expect(output.data.id).toBe('123'); + }); + + it('should validate list contract input', () => { + const input = ApiContracts.list.input.parse({ + object: 'account', + fields: ['name', 'email'], + }); + + expect(input.object).toBe('account'); + }); + + it('should validate bulk delete contract input', () => { + const input = ApiContracts.bulkDelete.input.parse({ + ids: ['1', '2', '3'], + }); + + expect(input.ids).toHaveLength(3); + }); +}); diff --git a/packages/spec/src/data/sharing.test.ts b/packages/spec/src/data/sharing.test.ts new file mode 100644 index 0000000..5a038e3 --- /dev/null +++ b/packages/spec/src/data/sharing.test.ts @@ -0,0 +1,293 @@ +import { describe, it, expect } from 'vitest'; +import { + SharingRuleSchema, + SharingRuleType, + SharingLevel, + OWDModel, + type SharingRule, +} from './sharing.zod'; + +describe('SharingRuleType', () => { + it('should accept valid sharing rule types', () => { + const validTypes = ['owner', 'criteria', 'manual', 'guest']; + + validTypes.forEach(type => { + expect(() => SharingRuleType.parse(type)).not.toThrow(); + }); + }); + + it('should reject invalid sharing rule types', () => { + expect(() => SharingRuleType.parse('automatic')).toThrow(); + expect(() => SharingRuleType.parse('public')).toThrow(); + expect(() => SharingRuleType.parse('')).toThrow(); + }); +}); + +describe('SharingLevel', () => { + it('should accept valid sharing levels', () => { + const validLevels = ['read', 'edit']; + + validLevels.forEach(level => { + expect(() => SharingLevel.parse(level)).not.toThrow(); + }); + }); + + it('should reject invalid sharing levels', () => { + expect(() => SharingLevel.parse('write')).toThrow(); + expect(() => SharingLevel.parse('delete')).toThrow(); + expect(() => SharingLevel.parse('full')).toThrow(); + }); +}); + +describe('OWDModel', () => { + it('should accept valid OWD models', () => { + const validModels = ['private', 'public_read', 'public_read_write']; + + validModels.forEach(model => { + expect(() => OWDModel.parse(model)).not.toThrow(); + }); + }); + + it('should reject invalid OWD models', () => { + expect(() => OWDModel.parse('public')).toThrow(); + expect(() => OWDModel.parse('public_write')).toThrow(); + expect(() => OWDModel.parse('')).toThrow(); + }); +}); + +describe('SharingRuleSchema', () => { + it('should accept valid minimal sharing rule', () => { + const rule: SharingRule = { + name: 'sales_team_access', + object: 'opportunity', + sharedWith: 'group_sales_team', + }; + + expect(() => SharingRuleSchema.parse(rule)).not.toThrow(); + }); + + it('should validate rule name format (snake_case)', () => { + expect(() => SharingRuleSchema.parse({ + name: 'valid_rule_name', + object: 'account', + sharedWith: 'group_id', + })).not.toThrow(); + + expect(() => SharingRuleSchema.parse({ + name: 'InvalidRule', + object: 'account', + sharedWith: 'group_id', + })).toThrow(); + + expect(() => SharingRuleSchema.parse({ + name: 'invalid-rule', + object: 'account', + sharedWith: 'group_id', + })).toThrow(); + }); + + it('should apply default values', () => { + const rule = SharingRuleSchema.parse({ + name: 'test_rule', + object: 'account', + sharedWith: 'group_id', + }); + + expect(rule.active).toBe(true); + expect(rule.type).toBe('criteria'); + expect(rule.accessLevel).toBe('read'); + }); + + it('should accept sharing rule with all fields', () => { + const rule = SharingRuleSchema.parse({ + name: 'full_sharing_rule', + label: 'Full Sharing Rule', + active: true, + object: 'opportunity', + type: 'criteria', + criteria: "stage = 'Closed Won' AND amount > 100000", + accessLevel: 'edit', + sharedWith: 'group_executive_team', + }); + + expect(rule.label).toBe('Full Sharing Rule'); + expect(rule.criteria).toContain('Closed Won'); + }); + + it('should accept different sharing rule types', () => { + const types: Array = ['owner', 'criteria', 'manual', 'guest']; + + types.forEach(type => { + const rule = SharingRuleSchema.parse({ + name: 'test_rule', + object: 'account', + type, + sharedWith: 'group_id', + }); + expect(rule.type).toBe(type); + }); + }); + + it('should accept different access levels', () => { + const levels: Array = ['read', 'edit']; + + levels.forEach(level => { + const rule = SharingRuleSchema.parse({ + name: 'test_rule', + object: 'account', + accessLevel: level, + sharedWith: 'group_id', + }); + expect(rule.accessLevel).toBe(level); + }); + }); + + it('should accept owner-based sharing rule', () => { + const rule = SharingRuleSchema.parse({ + name: 'owner_hierarchy_rule', + object: 'account', + type: 'owner', + accessLevel: 'read', + sharedWith: 'role_sales_manager', + }); + + expect(rule.type).toBe('owner'); + }); + + it('should accept criteria-based sharing rule', () => { + const rule = SharingRuleSchema.parse({ + name: 'high_value_accounts', + object: 'account', + type: 'criteria', + criteria: "annual_revenue > 1000000 AND status = 'Active'", + accessLevel: 'read', + sharedWith: 'group_executive_team', + }); + + expect(rule.type).toBe('criteria'); + expect(rule.criteria).toBeDefined(); + }); + + it('should accept manual sharing rule', () => { + const rule = SharingRuleSchema.parse({ + name: 'manual_share', + object: 'opportunity', + type: 'manual', + accessLevel: 'edit', + sharedWith: 'user_john_doe', + }); + + expect(rule.type).toBe('manual'); + }); + + it('should accept guest sharing rule', () => { + const rule = SharingRuleSchema.parse({ + name: 'public_access', + object: 'knowledge_article', + type: 'guest', + accessLevel: 'read', + sharedWith: 'guest_users', + }); + + expect(rule.type).toBe('guest'); + }); + + it('should accept inactive sharing rule', () => { + const rule = SharingRuleSchema.parse({ + name: 'disabled_rule', + object: 'account', + active: false, + sharedWith: 'group_id', + }); + + expect(rule.active).toBe(false); + }); + + it('should handle sales territory sharing', () => { + const rule = SharingRuleSchema.parse({ + name: 'west_coast_territory', + label: 'West Coast Territory Access', + object: 'account', + type: 'criteria', + criteria: "billing_state IN ('CA', 'OR', 'WA')", + accessLevel: 'edit', + sharedWith: 'group_west_coast_sales', + }); + + expect(rule.criteria).toContain('CA'); + }); + + it('should handle department-based sharing', () => { + const rule = SharingRuleSchema.parse({ + name: 'finance_department_access', + object: 'invoice', + type: 'criteria', + criteria: "department = 'Finance'", + accessLevel: 'edit', + sharedWith: 'group_finance_team', + }); + + expect(rule.object).toBe('invoice'); + }); + + it('should handle read-only sharing', () => { + const rule = SharingRuleSchema.parse({ + name: 'readonly_access', + object: 'contract', + type: 'criteria', + criteria: "status = 'Executed'", + accessLevel: 'read', + sharedWith: 'group_all_users', + }); + + expect(rule.accessLevel).toBe('read'); + }); + + it('should handle edit access sharing', () => { + const rule = SharingRuleSchema.parse({ + name: 'edit_access', + object: 'opportunity', + type: 'criteria', + criteria: "stage != 'Closed Won'", + accessLevel: 'edit', + sharedWith: 'group_sales_reps', + }); + + expect(rule.accessLevel).toBe('edit'); + }); + + it('should reject sharing rule without required fields', () => { + expect(() => SharingRuleSchema.parse({ + object: 'account', + sharedWith: 'group_id', + })).toThrow(); + + expect(() => SharingRuleSchema.parse({ + name: 'test_rule', + sharedWith: 'group_id', + })).toThrow(); + + expect(() => SharingRuleSchema.parse({ + name: 'test_rule', + object: 'account', + })).toThrow(); + }); + + it('should reject invalid sharing rule type', () => { + expect(() => SharingRuleSchema.parse({ + name: 'test_rule', + object: 'account', + type: 'invalid_type', + sharedWith: 'group_id', + })).toThrow(); + }); + + it('should reject invalid access level', () => { + expect(() => SharingRuleSchema.parse({ + name: 'test_rule', + object: 'account', + accessLevel: 'delete', + sharedWith: 'group_id', + })).toThrow(); + }); +}); diff --git a/packages/spec/src/system/api.test.ts b/packages/spec/src/system/api.test.ts new file mode 100644 index 0000000..0ef8dfc --- /dev/null +++ b/packages/spec/src/system/api.test.ts @@ -0,0 +1,441 @@ +import { describe, it, expect } from 'vitest'; +import { + ApiEndpointSchema, + RateLimitSchema, + ApiMappingSchema, + HttpMethod, + ApiEndpoint, +} from './api.zod'; + +describe('HttpMethod', () => { + it('should accept valid HTTP methods', () => { + const validMethods = ['GET', 'POST', 'PUT', 'DELETE', 'PATCH']; + + validMethods.forEach(method => { + expect(() => HttpMethod.parse(method)).not.toThrow(); + }); + }); + + it('should reject invalid HTTP methods', () => { + expect(() => HttpMethod.parse('HEAD')).toThrow(); + expect(() => HttpMethod.parse('OPTIONS')).toThrow(); + expect(() => HttpMethod.parse('get')).toThrow(); + }); +}); + +describe('RateLimitSchema', () => { + it('should accept valid rate limit', () => { + const rateLimit = RateLimitSchema.parse({}); + + expect(rateLimit.enabled).toBe(false); + expect(rateLimit.windowMs).toBe(60000); + expect(rateLimit.maxRequests).toBe(100); + }); + + it('should accept custom rate limit', () => { + const rateLimit = RateLimitSchema.parse({ + enabled: true, + windowMs: 3600000, + maxRequests: 1000, + }); + + expect(rateLimit.enabled).toBe(true); + expect(rateLimit.windowMs).toBe(3600000); + expect(rateLimit.maxRequests).toBe(1000); + }); + + it('should accept enabled rate limit', () => { + const rateLimit = RateLimitSchema.parse({ + enabled: true, + }); + + expect(rateLimit.enabled).toBe(true); + }); +}); + +describe('ApiMappingSchema', () => { + it('should accept valid minimal mapping', () => { + const mapping = ApiMappingSchema.parse({ + source: 'firstName', + target: 'first_name', + }); + + expect(mapping.source).toBe('firstName'); + expect(mapping.target).toBe('first_name'); + }); + + it('should accept mapping with transform', () => { + const mapping = ApiMappingSchema.parse({ + source: 'price', + target: 'amount', + transform: 'convertToInt', + }); + + expect(mapping.transform).toBe('convertToInt'); + }); + + it('should accept nested path mapping', () => { + const mapping = ApiMappingSchema.parse({ + source: 'user.profile.email', + target: 'contact.email', + }); + + expect(mapping.source).toBe('user.profile.email'); + }); +}); + +describe('ApiEndpointSchema', () => { + it('should accept valid minimal endpoint', () => { + const endpoint = ApiEndpointSchema.parse({ + name: 'get_customers', + path: '/api/v1/customers', + method: 'GET', + type: 'object_operation', + target: 'customer', + }); + + expect(endpoint.name).toBe('get_customers'); + }); + + it('should validate endpoint name format (snake_case)', () => { + expect(() => ApiEndpointSchema.parse({ + name: 'valid_endpoint_name', + path: '/api/test', + method: 'GET', + type: 'flow', + target: 'flow_id', + })).not.toThrow(); + + expect(() => ApiEndpointSchema.parse({ + name: 'InvalidEndpoint', + path: '/api/test', + method: 'GET', + type: 'flow', + target: 'flow_id', + })).toThrow(); + + expect(() => ApiEndpointSchema.parse({ + name: 'invalid-endpoint', + path: '/api/test', + method: 'GET', + type: 'flow', + target: 'flow_id', + })).toThrow(); + }); + + it('should validate path format (must start with /)', () => { + expect(() => ApiEndpointSchema.parse({ + name: 'test_endpoint', + path: '/api/test', + method: 'GET', + type: 'flow', + target: 'flow_id', + })).not.toThrow(); + + expect(() => ApiEndpointSchema.parse({ + name: 'test_endpoint', + path: 'api/test', + method: 'GET', + type: 'flow', + target: 'flow_id', + })).toThrow(); + }); + + it('should apply default authRequired', () => { + const endpoint = ApiEndpointSchema.parse({ + name: 'test_endpoint', + path: '/api/test', + method: 'GET', + type: 'flow', + target: 'flow_id', + }); + + expect(endpoint.authRequired).toBe(true); + }); + + it('should accept endpoint with all fields', () => { + const endpoint = ApiEndpointSchema.parse({ + name: 'create_order', + path: '/api/v1/orders', + method: 'POST', + summary: 'Create a new order', + description: 'Creates a new order in the system', + type: 'flow', + target: 'order_creation_flow', + inputMapping: [ + { source: 'customer_id', target: 'customerId' }, + { source: 'items', target: 'orderItems' }, + ], + outputMapping: [ + { source: 'order_id', target: 'id' }, + { source: 'order_number', target: 'orderNumber' }, + ], + authRequired: true, + rateLimit: { + enabled: true, + windowMs: 60000, + maxRequests: 10, + }, + cacheTtl: 300, + }); + + expect(endpoint.summary).toBe('Create a new order'); + expect(endpoint.inputMapping).toHaveLength(2); + expect(endpoint.rateLimit?.enabled).toBe(true); + expect(endpoint.cacheTtl).toBe(300); + }); + + it('should accept different HTTP methods', () => { + const methods: Array = ['GET', 'POST', 'PUT', 'DELETE', 'PATCH']; + + methods.forEach(method => { + const endpoint = ApiEndpointSchema.parse({ + name: 'test_endpoint', + path: '/api/test', + method, + type: 'flow', + target: 'flow_id', + }); + expect(endpoint.method).toBe(method); + }); + }); + + it('should accept different implementation types', () => { + const types: Array = ['flow', 'script', 'object_operation', 'proxy']; + + types.forEach(type => { + const endpoint = ApiEndpointSchema.parse({ + name: 'test_endpoint', + path: '/api/test', + method: 'GET', + type, + target: 'target_id', + }); + expect(endpoint.type).toBe(type); + }); + }); + + it('should accept flow-based endpoint', () => { + const endpoint = ApiEndpointSchema.parse({ + name: 'run_approval_flow', + path: '/api/v1/approve', + method: 'POST', + type: 'flow', + target: 'approval_flow_id', + }); + + expect(endpoint.type).toBe('flow'); + }); + + it('should accept script-based endpoint', () => { + const endpoint = ApiEndpointSchema.parse({ + name: 'calculate_tax', + path: '/api/v1/tax', + method: 'POST', + type: 'script', + target: 'tax_calculator_script', + }); + + expect(endpoint.type).toBe('script'); + }); + + it('should accept object operation endpoint', () => { + const endpoint = ApiEndpointSchema.parse({ + name: 'get_accounts', + path: '/api/v1/accounts', + method: 'GET', + type: 'object_operation', + target: 'account', + objectParams: { + object: 'account', + operation: 'find', + }, + }); + + expect(endpoint.type).toBe('object_operation'); + expect(endpoint.objectParams?.operation).toBe('find'); + }); + + it('should accept different object operations', () => { + const operations: Array['operation']>> = ['find', 'get', 'create', 'update', 'delete']; + + operations.forEach(operation => { + const endpoint = ApiEndpointSchema.parse({ + name: 'test_endpoint', + path: '/api/test', + method: 'POST', + type: 'object_operation', + target: 'object_name', + objectParams: { + object: 'test_object', + operation, + }, + }); + expect(endpoint.objectParams?.operation).toBe(operation); + }); + }); + + it('should accept proxy endpoint', () => { + const endpoint = ApiEndpointSchema.parse({ + name: 'external_api_proxy', + path: '/api/v1/proxy/external', + method: 'GET', + type: 'proxy', + target: 'https://external-api.example.com/endpoint', + }); + + expect(endpoint.type).toBe('proxy'); + }); + + it('should accept endpoint with input/output mapping', () => { + const endpoint = ApiEndpointSchema.parse({ + name: 'transform_data', + path: '/api/v1/transform', + method: 'POST', + type: 'flow', + target: 'transform_flow', + inputMapping: [ + { source: 'firstName', target: 'first_name' }, + { source: 'lastName', target: 'last_name' }, + { source: 'email', target: 'email_address', transform: 'toLowerCase' }, + ], + outputMapping: [ + { source: 'user_id', target: 'id' }, + { source: 'created_at', target: 'createdAt' }, + ], + }); + + expect(endpoint.inputMapping).toHaveLength(3); + expect(endpoint.outputMapping).toHaveLength(2); + }); + + it('should accept endpoint with rate limiting', () => { + const endpoint = ApiEndpointSchema.parse({ + name: 'limited_endpoint', + path: '/api/v1/limited', + method: 'POST', + type: 'flow', + target: 'flow_id', + rateLimit: { + enabled: true, + windowMs: 3600000, + maxRequests: 100, + }, + }); + + expect(endpoint.rateLimit?.enabled).toBe(true); + expect(endpoint.rateLimit?.maxRequests).toBe(100); + }); + + it('should accept endpoint with caching', () => { + const endpoint = ApiEndpointSchema.parse({ + name: 'cached_endpoint', + path: '/api/v1/cached', + method: 'GET', + type: 'object_operation', + target: 'data', + cacheTtl: 600, + }); + + expect(endpoint.cacheTtl).toBe(600); + }); + + it('should accept public endpoint (no auth required)', () => { + const endpoint = ApiEndpointSchema.parse({ + name: 'public_endpoint', + path: '/api/v1/public', + method: 'GET', + type: 'flow', + target: 'public_flow', + authRequired: false, + }); + + expect(endpoint.authRequired).toBe(false); + }); + + it('should accept endpoint with documentation', () => { + const endpoint = ApiEndpointSchema.parse({ + name: 'documented_endpoint', + path: '/api/v1/documented', + method: 'POST', + summary: 'Create a resource', + description: 'This endpoint creates a new resource with the provided data', + type: 'object_operation', + target: 'resource', + }); + + expect(endpoint.summary).toBe('Create a resource'); + expect(endpoint.description).toBeDefined(); + }); + + it('should accept CRUD endpoints', () => { + const endpoints = [ + { method: 'GET' as const, path: '/api/v1/users', operation: 'find' as const }, + { method: 'GET' as const, path: '/api/v1/users/:id', operation: 'get' as const }, + { method: 'POST' as const, path: '/api/v1/users', operation: 'create' as const }, + { method: 'PUT' as const, path: '/api/v1/users/:id', operation: 'update' as const }, + { method: 'DELETE' as const, path: '/api/v1/users/:id', operation: 'delete' as const }, + ]; + + endpoints.forEach(({ method, path, operation }) => { + const endpoint = ApiEndpointSchema.parse({ + name: `${operation}_user`, + path, + method, + type: 'object_operation', + target: 'user', + objectParams: { + object: 'user', + operation, + }, + }); + expect(endpoint.method).toBe(method); + expect(endpoint.objectParams?.operation).toBe(operation); + }); + }); + + it('should use ApiEndpoint factory', () => { + const config = ApiEndpoint.create({ + name: 'factory_endpoint', + path: '/api/test', + method: 'GET', + type: 'flow', + target: 'flow_id', + }); + + expect(config.name).toBe('factory_endpoint'); + }); + + it('should reject endpoint without required fields', () => { + expect(() => ApiEndpointSchema.parse({ + path: '/api/test', + method: 'GET', + type: 'flow', + target: 'flow_id', + })).toThrow(); + + expect(() => ApiEndpointSchema.parse({ + name: 'test_endpoint', + method: 'GET', + type: 'flow', + target: 'flow_id', + })).toThrow(); + + expect(() => ApiEndpointSchema.parse({ + name: 'test_endpoint', + path: '/api/test', + type: 'flow', + target: 'flow_id', + })).toThrow(); + }); + + it('should reject invalid implementation type', () => { + expect(() => ApiEndpointSchema.parse({ + name: 'test_endpoint', + path: '/api/test', + method: 'GET', + type: 'invalid_type', + target: 'target', + })).toThrow(); + }); +}); diff --git a/packages/spec/src/system/datasource.test.ts b/packages/spec/src/system/datasource.test.ts new file mode 100644 index 0000000..0defb15 --- /dev/null +++ b/packages/spec/src/system/datasource.test.ts @@ -0,0 +1,426 @@ +import { describe, it, expect } from 'vitest'; +import { + DatasourceSchema, + DatasourceCapabilities, + DriverDefinitionSchema, + DriverType, + BuiltInDrivers, + type Datasource, + type DatasourceConfig, +} from './datasource.zod'; + +describe('DriverType', () => { + it('should accept any string as driver type', () => { + expect(() => DriverType.parse('postgres')).not.toThrow(); + expect(() => DriverType.parse('custom.driver')).not.toThrow(); + expect(() => DriverType.parse('com.vendor.snowflake')).not.toThrow(); + }); + + it('should accept built-in drivers', () => { + BuiltInDrivers.forEach(driver => { + expect(() => DriverType.parse(driver)).not.toThrow(); + }); + }); +}); + +describe('DatasourceCapabilities', () => { + it('should accept empty capabilities with defaults', () => { + const capabilities = DatasourceCapabilities.parse({}); + + expect(capabilities.transactions).toBe(false); + expect(capabilities.queryFilters).toBe(false); + expect(capabilities.queryAggregations).toBe(false); + expect(capabilities.querySorting).toBe(false); + expect(capabilities.queryPagination).toBe(false); + expect(capabilities.queryWindowFunctions).toBe(false); + expect(capabilities.querySubqueries).toBe(false); + expect(capabilities.joins).toBe(false); + expect(capabilities.fullTextSearch).toBe(false); + expect(capabilities.readOnly).toBe(false); + expect(capabilities.dynamicSchema).toBe(false); + }); + + it('should accept full capabilities for SQL database', () => { + const capabilities = DatasourceCapabilities.parse({ + transactions: true, + queryFilters: true, + queryAggregations: true, + querySorting: true, + queryPagination: true, + queryWindowFunctions: true, + querySubqueries: true, + joins: true, + fullTextSearch: true, + readOnly: false, + dynamicSchema: false, + }); + + expect(capabilities.transactions).toBe(true); + expect(capabilities.queryWindowFunctions).toBe(true); + }); + + it('should accept limited capabilities for NoSQL database', () => { + const capabilities = DatasourceCapabilities.parse({ + transactions: false, + queryFilters: true, + queryAggregations: true, + querySorting: true, + queryPagination: true, + joins: false, + dynamicSchema: true, + }); + + expect(capabilities.joins).toBe(false); + expect(capabilities.dynamicSchema).toBe(true); + }); + + it('should accept read-only capabilities', () => { + const capabilities = DatasourceCapabilities.parse({ + readOnly: true, + queryFilters: true, + querySorting: true, + }); + + expect(capabilities.readOnly).toBe(true); + }); + + it('should accept capabilities for Excel/CSV', () => { + const capabilities = DatasourceCapabilities.parse({ + transactions: false, + queryFilters: true, + querySorting: true, + joins: false, + readOnly: false, + }); + + expect(capabilities.transactions).toBe(false); + }); +}); + +describe('DriverDefinitionSchema', () => { + it('should accept valid driver definition', () => { + const driver = DriverDefinitionSchema.parse({ + id: 'postgres', + label: 'PostgreSQL', + configSchema: { + type: 'object', + properties: { + host: { type: 'string' }, + port: { type: 'number' }, + database: { type: 'string' }, + }, + }, + }); + + expect(driver.id).toBe('postgres'); + expect(driver.label).toBe('PostgreSQL'); + }); + + it('should accept driver with all fields', () => { + const driver = DriverDefinitionSchema.parse({ + id: 'postgres', + label: 'PostgreSQL', + description: 'PostgreSQL database driver', + icon: 'database', + configSchema: { + type: 'object', + required: ['host', 'database'], + properties: { + host: { type: 'string' }, + port: { type: 'number', default: 5432 }, + database: { type: 'string' }, + username: { type: 'string' }, + password: { type: 'string' }, + }, + }, + capabilities: { + transactions: true, + queryFilters: true, + queryAggregations: true, + }, + }); + + expect(driver.description).toBe('PostgreSQL database driver'); + expect(driver.capabilities).toBeDefined(); + }); + + it('should accept MongoDB driver definition', () => { + const driver = DriverDefinitionSchema.parse({ + id: 'mongo', + label: 'MongoDB', + configSchema: { + type: 'object', + properties: { + connectionString: { type: 'string' }, + }, + }, + }); + + expect(driver.id).toBe('mongo'); + }); + + it('should accept REST API driver definition', () => { + const driver = DriverDefinitionSchema.parse({ + id: 'rest_api', + label: 'REST API', + configSchema: { + type: 'object', + properties: { + baseUrl: { type: 'string' }, + apiKey: { type: 'string' }, + }, + }, + }); + + expect(driver.id).toBe('rest_api'); + }); +}); + +describe('DatasourceSchema', () => { + it('should accept valid minimal datasource', () => { + const datasource: Datasource = { + name: 'main_db', + driver: 'postgres', + config: { + host: 'localhost', + port: 5432, + database: 'mydb', + }, + }; + + expect(() => DatasourceSchema.parse(datasource)).not.toThrow(); + }); + + it('should validate datasource name format (snake_case)', () => { + expect(() => DatasourceSchema.parse({ + name: 'valid_datasource_name', + driver: 'postgres', + config: {}, + })).not.toThrow(); + + expect(() => DatasourceSchema.parse({ + name: 'InvalidDatasource', + driver: 'postgres', + config: {}, + })).toThrow(); + + expect(() => DatasourceSchema.parse({ + name: 'invalid-datasource', + driver: 'postgres', + config: {}, + })).toThrow(); + }); + + it('should apply default active value', () => { + const datasource = DatasourceSchema.parse({ + name: 'test_db', + driver: 'postgres', + config: {}, + }); + + expect(datasource.active).toBe(true); + }); + + it('should accept datasource with all fields', () => { + const datasource = DatasourceSchema.parse({ + name: 'production_db', + label: 'Production Database', + driver: 'postgres', + config: { + host: 'db.example.com', + port: 5432, + database: 'production', + username: 'app_user', + password: '${DB_PASSWORD}', + ssl: true, + }, + capabilities: { + transactions: true, + queryFilters: true, + queryAggregations: true, + }, + description: 'Main production PostgreSQL database', + active: true, + }); + + expect(datasource.label).toBe('Production Database'); + expect(datasource.description).toBeDefined(); + expect(datasource.capabilities).toBeDefined(); + }); + + it('should accept PostgreSQL datasource', () => { + const datasource = DatasourceSchema.parse({ + name: 'postgres_db', + driver: 'postgres', + config: { + host: 'localhost', + port: 5432, + database: 'mydb', + username: 'user', + password: 'pass', + }, + }); + + expect(datasource.driver).toBe('postgres'); + }); + + it('should accept MySQL datasource', () => { + const datasource = DatasourceSchema.parse({ + name: 'mysql_db', + driver: 'mysql', + config: { + host: 'localhost', + port: 3306, + database: 'mydb', + }, + }); + + expect(datasource.driver).toBe('mysql'); + }); + + it('should accept MongoDB datasource', () => { + const datasource = DatasourceSchema.parse({ + name: 'mongo_db', + driver: 'mongo', + config: { + connectionString: 'mongodb://localhost:27017/mydb', + }, + }); + + expect(datasource.driver).toBe('mongo'); + }); + + it('should accept Redis datasource', () => { + const datasource = DatasourceSchema.parse({ + name: 'redis_cache', + driver: 'redis', + config: { + host: 'localhost', + port: 6379, + }, + }); + + expect(datasource.driver).toBe('redis'); + }); + + it('should accept Salesforce datasource', () => { + const datasource = DatasourceSchema.parse({ + name: 'salesforce', + driver: 'salesforce', + config: { + instanceUrl: 'https://example.my.salesforce.com', + clientId: 'client_id', + clientSecret: '${SF_CLIENT_SECRET}', + }, + }); + + expect(datasource.driver).toBe('salesforce'); + }); + + it('should accept Excel datasource', () => { + const datasource = DatasourceSchema.parse({ + name: 'excel_import', + driver: 'excel', + config: { + filePath: '/data/import.xlsx', + }, + }); + + expect(datasource.driver).toBe('excel'); + }); + + it('should accept REST API datasource', () => { + const datasource = DatasourceSchema.parse({ + name: 'external_api', + driver: 'rest_api', + config: { + baseUrl: 'https://api.example.com', + apiKey: '${API_KEY}', + }, + }); + + expect(datasource.driver).toBe('rest_api'); + }); + + it('should accept inactive datasource', () => { + const datasource = DatasourceSchema.parse({ + name: 'disabled_db', + driver: 'postgres', + config: {}, + active: false, + }); + + expect(datasource.active).toBe(false); + }); + + it('should accept datasource with capability overrides', () => { + const datasource = DatasourceSchema.parse({ + name: 'custom_db', + driver: 'postgres', + config: {}, + capabilities: { + queryWindowFunctions: false, + querySubqueries: false, + }, + }); + + expect(datasource.capabilities?.queryWindowFunctions).toBe(false); + }); + + it('should accept datasource with environment variables in config', () => { + const datasource = DatasourceSchema.parse({ + name: 'secure_db', + driver: 'postgres', + config: { + host: '${DB_HOST}', + port: 5432, + database: '${DB_NAME}', + username: '${DB_USER}', + password: '${DB_PASSWORD}', + }, + }); + + expect(datasource.config.password).toBe('${DB_PASSWORD}'); + }); + + it('should accept datasource with complex config', () => { + const datasource = DatasourceSchema.parse({ + name: 'complex_db', + driver: 'postgres', + config: { + host: 'localhost', + port: 5432, + database: 'mydb', + pool: { + min: 2, + max: 10, + idleTimeoutMillis: 30000, + }, + ssl: { + rejectUnauthorized: false, + ca: 'certificate_content', + }, + }, + }); + + expect(datasource.config.pool).toBeDefined(); + expect(datasource.config.ssl).toBeDefined(); + }); + + it('should reject datasource without required fields', () => { + expect(() => DatasourceSchema.parse({ + driver: 'postgres', + config: {}, + })).toThrow(); + + expect(() => DatasourceSchema.parse({ + name: 'test_db', + config: {}, + })).toThrow(); + + expect(() => DatasourceSchema.parse({ + name: 'test_db', + driver: 'postgres', + })).toThrow(); + }); +}); diff --git a/packages/spec/src/ui/page.test.ts b/packages/spec/src/ui/page.test.ts new file mode 100644 index 0000000..6a567ff --- /dev/null +++ b/packages/spec/src/ui/page.test.ts @@ -0,0 +1,385 @@ +import { describe, it, expect } from 'vitest'; +import { + PageSchema, + PageComponentSchema, + PageRegionSchema, + type Page, + type PageComponent, + type PageRegion, +} from './page.zod'; + +describe('PageComponentSchema', () => { + it('should accept valid minimal component', () => { + const component: PageComponent = { + type: 'steedos-labs.related-list', + properties: {}, + }; + + expect(() => PageComponentSchema.parse(component)).not.toThrow(); + }); + + it('should accept component with all fields', () => { + const component = PageComponentSchema.parse({ + type: 'steedos-labs.related-list', + id: 'related_contacts', + label: 'Related Contacts', + properties: { + objectName: 'contact', + filterField: 'account_id', + columns: ['name', 'email', 'phone'], + }, + visibility: 'record.type == "Customer"', + }); + + expect(component.id).toBe('related_contacts'); + expect(component.label).toBe('Related Contacts'); + expect(component.visibility).toBeDefined(); + }); + + it('should accept component with complex properties', () => { + const component = PageComponentSchema.parse({ + type: 'custom.dashboard-widget', + properties: { + title: 'Sales Pipeline', + chartType: 'funnel', + dataSource: 'opportunity', + filters: { stage: { $ne: 'Closed Lost' } }, + groupBy: 'stage', + aggregate: 'sum', + field: 'amount', + }, + }); + + expect(component.properties.title).toBe('Sales Pipeline'); + }); +}); + +describe('PageRegionSchema', () => { + it('should accept valid minimal region', () => { + const region: PageRegion = { + name: 'main', + components: [], + }; + + expect(() => PageRegionSchema.parse(region)).not.toThrow(); + }); + + it('should accept region with all fields', () => { + const region = PageRegionSchema.parse({ + name: 'sidebar', + width: 'small', + components: [ + { + type: 'steedos-labs.quick-actions', + properties: { actions: ['edit', 'delete'] }, + }, + ], + }); + + expect(region.name).toBe('sidebar'); + expect(region.width).toBe('small'); + expect(region.components).toHaveLength(1); + }); + + it('should accept different region widths', () => { + const widths: Array> = ['small', 'medium', 'large', 'full']; + + widths.forEach(width => { + const region = PageRegionSchema.parse({ + name: 'test', + width, + components: [], + }); + expect(region.width).toBe(width); + }); + }); + + it('should accept region with multiple components', () => { + const region = PageRegionSchema.parse({ + name: 'main', + components: [ + { type: 'component.header', properties: {} }, + { type: 'component.body', properties: {} }, + { type: 'component.footer', properties: {} }, + ], + }); + + expect(region.components).toHaveLength(3); + }); +}); + +describe('PageSchema', () => { + it('should accept valid minimal page', () => { + const page: Page = { + name: 'account_record_page', + label: 'Account Record Page', + regions: [], + }; + + expect(() => PageSchema.parse(page)).not.toThrow(); + }); + + it('should validate page name format (snake_case)', () => { + expect(() => PageSchema.parse({ + name: 'valid_page_name', + label: 'Valid Page', + regions: [], + })).not.toThrow(); + + expect(() => PageSchema.parse({ + name: 'InvalidPage', + label: 'Invalid', + regions: [], + })).toThrow(); + + expect(() => PageSchema.parse({ + name: 'invalid-page', + label: 'Invalid', + regions: [], + })).toThrow(); + }); + + it('should apply default values', () => { + const page = PageSchema.parse({ + name: 'test_page', + label: 'Test Page', + regions: [], + }); + + expect(page.type).toBe('record'); + expect(page.template).toBe('default'); + expect(page.isDefault).toBe(false); + }); + + it('should accept page with all fields', () => { + const page = PageSchema.parse({ + name: 'account_record_page', + label: 'Account Record Page', + description: 'Custom record page for accounts', + type: 'record', + object: 'account', + template: 'header-sidebar-main', + regions: [ + { + name: 'header', + components: [ + { type: 'record.header', properties: {} }, + ], + }, + { + name: 'sidebar', + width: 'small', + components: [ + { type: 'record.details', properties: {} }, + ], + }, + { + name: 'main', + width: 'large', + components: [ + { type: 'related.list', properties: { objectName: 'contact' } }, + ], + }, + ], + isDefault: true, + assignedProfiles: ['admin', 'sales_user'], + }); + + expect(page.object).toBe('account'); + expect(page.regions).toHaveLength(3); + expect(page.isDefault).toBe(true); + }); + + it('should accept different page types', () => { + const types: Array = ['record', 'home', 'app', 'utility']; + + types.forEach(type => { + const page = PageSchema.parse({ + name: 'test_page', + label: 'Test Page', + type, + regions: [], + }); + expect(page.type).toBe(type); + }); + }); + + it('should accept record page', () => { + const page = PageSchema.parse({ + name: 'opportunity_page', + label: 'Opportunity Page', + type: 'record', + object: 'opportunity', + regions: [ + { + name: 'main', + components: [ + { type: 'record.form', properties: {} }, + ], + }, + ], + }); + + expect(page.type).toBe('record'); + expect(page.object).toBe('opportunity'); + }); + + it('should accept home page', () => { + const page = PageSchema.parse({ + name: 'sales_home', + label: 'Sales Home', + type: 'home', + regions: [ + { + name: 'main', + components: [ + { type: 'dashboard.widget', properties: { dashboardId: 'sales_dashboard' } }, + ], + }, + ], + }); + + expect(page.type).toBe('home'); + }); + + it('should accept app page', () => { + const page = PageSchema.parse({ + name: 'sales_app', + label: 'Sales App', + type: 'app', + regions: [ + { + name: 'main', + components: [ + { type: 'app.navigation', properties: {} }, + ], + }, + ], + }); + + expect(page.type).toBe('app'); + }); + + it('should accept utility page', () => { + const page = PageSchema.parse({ + name: 'notes_utility', + label: 'Notes Utility', + type: 'utility', + regions: [ + { + name: 'main', + components: [ + { type: 'utility.notes', properties: {} }, + ], + }, + ], + }); + + expect(page.type).toBe('utility'); + }); + + it('should accept page with profile assignments', () => { + const page = PageSchema.parse({ + name: 'custom_page', + label: 'Custom Page', + regions: [], + assignedProfiles: ['admin', 'sales_manager', 'sales_rep'], + }); + + expect(page.assignedProfiles).toHaveLength(3); + }); + + it('should accept page with custom template', () => { + const page = PageSchema.parse({ + name: 'custom_layout_page', + label: 'Custom Layout Page', + template: 'three-column-layout', + regions: [], + }); + + expect(page.template).toBe('three-column-layout'); + }); + + it('should accept default page', () => { + const page = PageSchema.parse({ + name: 'default_page', + label: 'Default Page', + isDefault: true, + regions: [], + }); + + expect(page.isDefault).toBe(true); + }); + + it('should accept page with multiple regions', () => { + const page = PageSchema.parse({ + name: 'multi_region_page', + label: 'Multi Region Page', + regions: [ + { name: 'header', components: [] }, + { name: 'sidebar', width: 'small', components: [] }, + { name: 'main', width: 'large', components: [] }, + { name: 'footer', components: [] }, + ], + }); + + expect(page.regions).toHaveLength(4); + }); + + it('should accept page with nested component properties', () => { + const page = PageSchema.parse({ + name: 'complex_page', + label: 'Complex Page', + regions: [ + { + name: 'main', + components: [ + { + type: 'custom.widget', + id: 'widget_1', + properties: { + config: { + nested: { + deeply: { + value: 'test', + }, + }, + }, + array: [1, 2, 3], + bool: true, + }, + }, + ], + }, + ], + }); + + expect(page.regions[0].components[0].properties.config).toBeDefined(); + }); + + it('should reject page without required fields', () => { + expect(() => PageSchema.parse({ + label: 'Test Page', + regions: [], + })).toThrow(); + + expect(() => PageSchema.parse({ + name: 'test_page', + regions: [], + })).toThrow(); + + expect(() => PageSchema.parse({ + name: 'test_page', + label: 'Test Page', + })).toThrow(); + }); + + it('should reject invalid page type', () => { + expect(() => PageSchema.parse({ + name: 'test_page', + label: 'Test Page', + type: 'invalid', + regions: [], + })).toThrow(); + }); +}); diff --git a/packages/spec/src/ui/report.test.ts b/packages/spec/src/ui/report.test.ts new file mode 100644 index 0000000..6ef16bc --- /dev/null +++ b/packages/spec/src/ui/report.test.ts @@ -0,0 +1,427 @@ +import { describe, it, expect } from 'vitest'; +import { + ReportSchema, + ReportColumnSchema, + ReportGroupingSchema, + ReportChartSchema, + ReportType, + Report, + type ReportColumn, + type ReportGrouping, + type ReportChart, +} from './report.zod'; + +describe('ReportType', () => { + it('should accept valid report types', () => { + const validTypes = ['tabular', 'summary', 'matrix', 'joined']; + + validTypes.forEach(type => { + expect(() => ReportType.parse(type)).not.toThrow(); + }); + }); + + it('should reject invalid report types', () => { + expect(() => ReportType.parse('list')).toThrow(); + expect(() => ReportType.parse('grid')).toThrow(); + expect(() => ReportType.parse('')).toThrow(); + }); +}); + +describe('ReportColumnSchema', () => { + it('should accept valid minimal column', () => { + const column: ReportColumn = { + field: 'name', + }; + + expect(() => ReportColumnSchema.parse(column)).not.toThrow(); + }); + + it('should accept column with all fields', () => { + const column = ReportColumnSchema.parse({ + field: 'amount', + label: 'Total Amount', + aggregate: 'sum', + }); + + expect(column.label).toBe('Total Amount'); + expect(column.aggregate).toBe('sum'); + }); + + it('should accept different aggregate functions', () => { + const aggregates: Array> = ['sum', 'avg', 'max', 'min', 'count', 'unique']; + + aggregates.forEach(aggregate => { + const column = ReportColumnSchema.parse({ + field: 'value', + aggregate, + }); + expect(column.aggregate).toBe(aggregate); + }); + }); + + it('should reject invalid aggregate function', () => { + expect(() => ReportColumnSchema.parse({ + field: 'value', + aggregate: 'median', + })).toThrow(); + }); +}); + +describe('ReportGroupingSchema', () => { + it('should accept valid minimal grouping', () => { + const grouping: ReportGrouping = { + field: 'category', + }; + + expect(() => ReportGroupingSchema.parse(grouping)).not.toThrow(); + }); + + it('should apply default sort order', () => { + const grouping = ReportGroupingSchema.parse({ + field: 'category', + }); + + expect(grouping.sortOrder).toBe('asc'); + }); + + it('should accept grouping with all fields', () => { + const grouping = ReportGroupingSchema.parse({ + field: 'created_date', + sortOrder: 'desc', + dateGranularity: 'month', + }); + + expect(grouping.sortOrder).toBe('desc'); + expect(grouping.dateGranularity).toBe('month'); + }); + + it('should accept different sort orders', () => { + const orders: Array = ['asc', 'desc']; + + orders.forEach(sortOrder => { + const grouping = ReportGroupingSchema.parse({ + field: 'name', + sortOrder, + }); + expect(grouping.sortOrder).toBe(sortOrder); + }); + }); + + it('should accept different date granularities', () => { + const granularities: Array> = ['day', 'week', 'month', 'quarter', 'year']; + + granularities.forEach(dateGranularity => { + const grouping = ReportGroupingSchema.parse({ + field: 'date', + dateGranularity, + }); + expect(grouping.dateGranularity).toBe(dateGranularity); + }); + }); +}); + +describe('ReportChartSchema', () => { + it('should accept valid minimal chart', () => { + const chart: ReportChart = { + type: 'bar', + xAxis: 'category', + yAxis: 'total_amount', + }; + + expect(() => ReportChartSchema.parse(chart)).not.toThrow(); + }); + + it('should apply default showLegend', () => { + const chart = ReportChartSchema.parse({ + type: 'pie', + xAxis: 'category', + yAxis: 'count', + }); + + expect(chart.showLegend).toBe(true); + }); + + it('should accept chart with all fields', () => { + const chart = ReportChartSchema.parse({ + type: 'column', + title: 'Sales by Region', + showLegend: false, + xAxis: 'region', + yAxis: 'total_sales', + }); + + expect(chart.title).toBe('Sales by Region'); + expect(chart.showLegend).toBe(false); + }); + + it('should accept different chart types', () => { + const types: Array = ['bar', 'column', 'line', 'pie', 'donut', 'scatter', 'funnel']; + + types.forEach(type => { + const chart = ReportChartSchema.parse({ + type, + xAxis: 'x', + yAxis: 'y', + }); + expect(chart.type).toBe(type); + }); + }); + + it('should reject invalid chart type', () => { + expect(() => ReportChartSchema.parse({ + type: 'area', + xAxis: 'x', + yAxis: 'y', + })).toThrow(); + }); +}); + +describe('ReportSchema', () => { + it('should accept valid minimal report', () => { + const report = Report.create({ + name: 'sales_report', + label: 'Sales Report', + objectName: 'opportunity', + columns: [ + { field: 'name' }, + { field: 'amount' }, + ], + }); + + expect(report.name).toBe('sales_report'); + }); + + it('should validate report name format (snake_case)', () => { + expect(() => ReportSchema.parse({ + name: 'valid_report_name', + label: 'Valid Report', + objectName: 'account', + columns: [{ field: 'name' }], + })).not.toThrow(); + + expect(() => ReportSchema.parse({ + name: 'InvalidReport', + label: 'Invalid', + objectName: 'account', + columns: [{ field: 'name' }], + })).toThrow(); + + expect(() => ReportSchema.parse({ + name: 'invalid-report', + label: 'Invalid', + objectName: 'account', + columns: [{ field: 'name' }], + })).toThrow(); + }); + + it('should apply default report type', () => { + const report = ReportSchema.parse({ + name: 'test_report', + label: 'Test Report', + objectName: 'account', + columns: [{ field: 'name' }], + }); + + expect(report.type).toBe('tabular'); + }); + + it('should accept report with all fields', () => { + const report = ReportSchema.parse({ + name: 'full_report', + label: 'Full Report', + description: 'Complete report with all features', + objectName: 'opportunity', + type: 'summary', + columns: [ + { field: 'stage' }, + { field: 'amount', aggregate: 'sum', label: 'Total Amount' }, + ], + groupingsDown: [ + { field: 'stage', sortOrder: 'asc' }, + ], + filter: { stage: { $ne: 'Closed Lost' } }, + chart: { + type: 'bar', + title: 'Opportunities by Stage', + xAxis: 'stage', + yAxis: 'total_amount', + }, + }); + + expect(report.type).toBe('summary'); + expect(report.groupingsDown).toHaveLength(1); + expect(report.chart).toBeDefined(); + }); + + it('should accept different report types', () => { + const types: Array = ['tabular', 'summary', 'matrix', 'joined']; + + types.forEach(type => { + const report = ReportSchema.parse({ + name: 'test_report', + label: 'Test', + objectName: 'account', + type, + columns: [{ field: 'name' }], + }); + expect(report.type).toBe(type); + }); + }); + + it('should accept tabular report', () => { + const report = ReportSchema.parse({ + name: 'account_list', + label: 'Account List', + objectName: 'account', + type: 'tabular', + columns: [ + { field: 'name' }, + { field: 'industry' }, + { field: 'annual_revenue' }, + ], + }); + + expect(report.type).toBe('tabular'); + expect(report.columns).toHaveLength(3); + }); + + it('should accept summary report with grouping', () => { + const report = ReportSchema.parse({ + name: 'sales_by_region', + label: 'Sales by Region', + objectName: 'opportunity', + type: 'summary', + columns: [ + { field: 'region' }, + { field: 'amount', aggregate: 'sum' }, + { field: 'opportunity_id', aggregate: 'count' }, + ], + groupingsDown: [ + { field: 'region', sortOrder: 'asc' }, + ], + }); + + expect(report.type).toBe('summary'); + expect(report.groupingsDown).toBeDefined(); + }); + + it('should accept matrix report with row and column groupings', () => { + const report = ReportSchema.parse({ + name: 'sales_matrix', + label: 'Sales Matrix', + objectName: 'opportunity', + type: 'matrix', + columns: [ + { field: 'amount', aggregate: 'sum' }, + ], + groupingsDown: [ + { field: 'stage' }, + ], + groupingsAcross: [ + { field: 'type' }, + ], + }); + + expect(report.type).toBe('matrix'); + expect(report.groupingsDown).toBeDefined(); + expect(report.groupingsAcross).toBeDefined(); + }); + + it('should accept report with filter criteria', () => { + const report = ReportSchema.parse({ + name: 'high_value_opportunities', + label: 'High Value Opportunities', + objectName: 'opportunity', + columns: [{ field: 'name' }, { field: 'amount' }], + filter: { + amount: { $gte: 100000 }, + stage: { $ne: 'Closed Lost' }, + }, + }); + + expect(report.filter).toBeDefined(); + }); + + it('should accept report with embedded chart', () => { + const report = ReportSchema.parse({ + name: 'revenue_chart', + label: 'Revenue Chart', + objectName: 'opportunity', + columns: [ + { field: 'stage' }, + { field: 'amount', aggregate: 'sum' }, + ], + groupingsDown: [{ field: 'stage' }], + chart: { + type: 'pie', + title: 'Revenue by Stage', + xAxis: 'stage', + yAxis: 'total_amount', + }, + }); + + expect(report.chart?.type).toBe('pie'); + }); + + it('should accept report with date grouping', () => { + const report = ReportSchema.parse({ + name: 'monthly_sales', + label: 'Monthly Sales', + objectName: 'opportunity', + type: 'summary', + columns: [ + { field: 'close_date' }, + { field: 'amount', aggregate: 'sum' }, + ], + groupingsDown: [ + { field: 'close_date', dateGranularity: 'month' }, + ], + }); + + expect(report.groupingsDown?.[0].dateGranularity).toBe('month'); + }); + + it('should accept report with multiple aggregations', () => { + const report = ReportSchema.parse({ + name: 'opportunity_stats', + label: 'Opportunity Statistics', + objectName: 'opportunity', + columns: [ + { field: 'stage' }, + { field: 'amount', aggregate: 'sum', label: 'Total' }, + { field: 'amount', aggregate: 'avg', label: 'Average' }, + { field: 'amount', aggregate: 'max', label: 'Maximum' }, + { field: 'opportunity_id', aggregate: 'count', label: 'Count' }, + ], + groupingsDown: [{ field: 'stage' }], + }); + + expect(report.columns).toHaveLength(5); + }); + + it('should reject report without required fields', () => { + expect(() => ReportSchema.parse({ + label: 'Test Report', + objectName: 'account', + columns: [{ field: 'name' }], + })).toThrow(); + + expect(() => ReportSchema.parse({ + name: 'test_report', + objectName: 'account', + columns: [{ field: 'name' }], + })).toThrow(); + + expect(() => ReportSchema.parse({ + name: 'test_report', + label: 'Test Report', + columns: [{ field: 'name' }], + })).toThrow(); + + expect(() => ReportSchema.parse({ + name: 'test_report', + label: 'Test Report', + objectName: 'account', + })).toThrow(); + }); +}); From e409495cad351ccbe60a380a9a570f703c771a24 Mon Sep 17 00:00:00 2001 From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com> Date: Thu, 22 Jan 2026 05:19:29 +0000 Subject: [PATCH 6/6] Fix code review issues: use proper Zod type inference instead of private internals Co-authored-by: hotlong <50353452+hotlong@users.noreply.github.com> --- packages/spec/src/system/api.test.ts | 6 +++--- packages/spec/src/ui/report.test.ts | 2 +- 2 files changed, 4 insertions(+), 4 deletions(-) diff --git a/packages/spec/src/system/api.test.ts b/packages/spec/src/system/api.test.ts index 0ef8dfc..f30e755 100644 --- a/packages/spec/src/system/api.test.ts +++ b/packages/spec/src/system/api.test.ts @@ -186,7 +186,7 @@ describe('ApiEndpointSchema', () => { }); it('should accept different HTTP methods', () => { - const methods: Array = ['GET', 'POST', 'PUT', 'DELETE', 'PATCH']; + const methods: Array> = ['GET', 'POST', 'PUT', 'DELETE', 'PATCH']; methods.forEach(method => { const endpoint = ApiEndpointSchema.parse({ @@ -201,7 +201,7 @@ describe('ApiEndpointSchema', () => { }); it('should accept different implementation types', () => { - const types: Array = ['flow', 'script', 'object_operation', 'proxy']; + const types: Array<'flow' | 'script' | 'object_operation' | 'proxy'> = ['flow', 'script', 'object_operation', 'proxy']; types.forEach(type => { const endpoint = ApiEndpointSchema.parse({ @@ -257,7 +257,7 @@ describe('ApiEndpointSchema', () => { }); it('should accept different object operations', () => { - const operations: Array['operation']>> = ['find', 'get', 'create', 'update', 'delete']; + const operations: Array<'find' | 'get' | 'create' | 'update' | 'delete'> = ['find', 'get', 'create', 'update', 'delete']; operations.forEach(operation => { const endpoint = ApiEndpointSchema.parse({ diff --git a/packages/spec/src/ui/report.test.ts b/packages/spec/src/ui/report.test.ts index 6ef16bc..f240fe0 100644 --- a/packages/spec/src/ui/report.test.ts +++ b/packages/spec/src/ui/report.test.ts @@ -254,7 +254,7 @@ describe('ReportSchema', () => { }); it('should accept different report types', () => { - const types: Array = ['tabular', 'summary', 'matrix', 'joined']; + const types: Array> = ['tabular', 'summary', 'matrix', 'joined']; types.forEach(type => { const report = ReportSchema.parse({