From 1c477616f43ea50c7e3269865cf2af001fa31ef0 Mon Sep 17 00:00:00 2001 From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com> Date: Sat, 31 Jan 2026 04:08:56 +0000 Subject: [PATCH 1/4] Initial plan From 63f28fb1d0e02cefc12bd77be9f621c47fb62f1a Mon Sep 17 00:00:00 2001 From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com> Date: Sat, 31 Jan 2026 04:15:40 +0000 Subject: [PATCH 2/4] Add comprehensive tests for API schemas (odata, protocol, router, rest-server) Co-authored-by: xuyushun441-sys <255036401+xuyushun441-sys@users.noreply.github.com> --- packages/spec/src/api/odata.test.ts | 617 ++++++++++++++++++++ packages/spec/src/api/protocol.test.ts | 645 +++++++++++++++++++++ packages/spec/src/api/rest-server.test.ts | 649 ++++++++++++++++++++++ packages/spec/src/api/router.test.ts | 557 +++++++++++++++++++ 4 files changed, 2468 insertions(+) create mode 100644 packages/spec/src/api/odata.test.ts create mode 100644 packages/spec/src/api/protocol.test.ts create mode 100644 packages/spec/src/api/rest-server.test.ts create mode 100644 packages/spec/src/api/router.test.ts diff --git a/packages/spec/src/api/odata.test.ts b/packages/spec/src/api/odata.test.ts new file mode 100644 index 000000000..f5a35fdc7 --- /dev/null +++ b/packages/spec/src/api/odata.test.ts @@ -0,0 +1,617 @@ +import { describe, it, expect } from 'vitest'; +import { + ODataQuerySchema, + ODataFilterOperatorSchema, + ODataFilterFunctionSchema, + ODataResponseSchema, + ODataErrorSchema, + ODataMetadataSchema, + OData, + type ODataQuery, +} from './odata.zod'; + +describe('ODataQuerySchema', () => { + describe('$select parameter', () => { + it('should accept string select', () => { + const query = ODataQuerySchema.parse({ + $select: 'name,email', + }); + + expect(query.$select).toBe('name,email'); + }); + + it('should accept array select', () => { + const query = ODataQuerySchema.parse({ + $select: ['name', 'email', 'phone'], + }); + + expect(query.$select).toEqual(['name', 'email', 'phone']); + }); + + it('should accept navigation path select', () => { + const query = ODataQuerySchema.parse({ + $select: 'id,customer/name', + }); + + expect(query.$select).toBe('id,customer/name'); + }); + }); + + describe('$filter parameter', () => { + it('should accept simple equality filter', () => { + const query = ODataQuerySchema.parse({ + $filter: "status eq 'active'", + }); + + expect(query.$filter).toBe("status eq 'active'"); + }); + + it('should accept complex filter with and/or', () => { + const query = ODataQuerySchema.parse({ + $filter: "country eq 'US' and revenue gt 100000", + }); + + expect(query.$filter).toBe("country eq 'US' and revenue gt 100000"); + }); + + it('should accept filter with functions', () => { + const query = ODataQuerySchema.parse({ + $filter: "contains(name, 'Smith')", + }); + + expect(query.$filter).toBe("contains(name, 'Smith')"); + }); + + it('should accept complex nested filter', () => { + const query = ODataQuerySchema.parse({ + $filter: "startswith(email, 'admin') and isActive eq true", + }); + + expect(query.$filter).toBeDefined(); + }); + }); + + describe('$orderby parameter', () => { + it('should accept string orderby', () => { + const query = ODataQuerySchema.parse({ + $orderby: 'name', + }); + + expect(query.$orderby).toBe('name'); + }); + + it('should accept orderby with direction', () => { + const query = ODataQuerySchema.parse({ + $orderby: 'revenue desc', + }); + + expect(query.$orderby).toBe('revenue desc'); + }); + + it('should accept array orderby', () => { + const query = ODataQuerySchema.parse({ + $orderby: ['name asc', 'revenue desc'], + }); + + expect(query.$orderby).toEqual(['name asc', 'revenue desc']); + }); + + it('should accept multiple fields orderby', () => { + const query = ODataQuerySchema.parse({ + $orderby: 'country asc, revenue desc', + }); + + expect(query.$orderby).toBe('country asc, revenue desc'); + }); + }); + + describe('$top and $skip parameters', () => { + it('should accept top parameter', () => { + const query = ODataQuerySchema.parse({ + $top: 10, + }); + + expect(query.$top).toBe(10); + }); + + it('should accept skip parameter', () => { + const query = ODataQuerySchema.parse({ + $skip: 20, + }); + + expect(query.$skip).toBe(20); + }); + + it('should accept both top and skip for pagination', () => { + const query = ODataQuerySchema.parse({ + $top: 25, + $skip: 50, + }); + + expect(query.$top).toBe(25); + expect(query.$skip).toBe(50); + }); + + it('should reject negative top', () => { + expect(() => ODataQuerySchema.parse({ + $top: -1, + })).toThrow(); + }); + + it('should reject negative skip', () => { + expect(() => ODataQuerySchema.parse({ + $skip: -5, + })).toThrow(); + }); + }); + + describe('$expand parameter', () => { + it('should accept string expand', () => { + const query = ODataQuerySchema.parse({ + $expand: 'orders', + }); + + expect(query.$expand).toBe('orders'); + }); + + it('should accept array expand', () => { + const query = ODataQuerySchema.parse({ + $expand: ['orders', 'customer', 'products'], + }); + + expect(query.$expand).toEqual(['orders', 'customer', 'products']); + }); + + it('should accept expand with nested options', () => { + const query = ODataQuerySchema.parse({ + $expand: 'orders($select=id,total)', + }); + + expect(query.$expand).toBe('orders($select=id,total)'); + }); + }); + + describe('$count parameter', () => { + it('should accept count true', () => { + const query = ODataQuerySchema.parse({ + $count: true, + }); + + expect(query.$count).toBe(true); + }); + + it('should accept count false', () => { + const query = ODataQuerySchema.parse({ + $count: false, + }); + + expect(query.$count).toBe(false); + }); + }); + + describe('$search parameter', () => { + it('should accept simple search', () => { + const query = ODataQuerySchema.parse({ + $search: 'John Smith', + }); + + expect(query.$search).toBe('John Smith'); + }); + + it('should accept search with AND', () => { + const query = ODataQuerySchema.parse({ + $search: 'urgent AND support', + }); + + expect(query.$search).toBe('urgent AND support'); + }); + }); + + describe('$format parameter', () => { + it('should accept json format', () => { + const query = ODataQuerySchema.parse({ + $format: 'json', + }); + + expect(query.$format).toBe('json'); + }); + + it('should accept xml format', () => { + const query = ODataQuerySchema.parse({ + $format: 'xml', + }); + + expect(query.$format).toBe('xml'); + }); + + it('should accept atom format', () => { + const query = ODataQuerySchema.parse({ + $format: 'atom', + }); + + expect(query.$format).toBe('atom'); + }); + + it('should reject invalid format', () => { + expect(() => ODataQuerySchema.parse({ + $format: 'csv', + })).toThrow(); + }); + }); + + describe('$apply parameter', () => { + it('should accept aggregation expression', () => { + const query = ODataQuerySchema.parse({ + $apply: 'groupby((country),aggregate(revenue with sum as totalRevenue))', + }); + + expect(query.$apply).toBeDefined(); + }); + }); + + describe('Complete Query', () => { + it('should accept comprehensive OData query', () => { + const query = ODataQuerySchema.parse({ + $select: ['name', 'email'], + $filter: "country eq 'US' and revenue gt 100000", + $orderby: 'revenue desc', + $top: 10, + $skip: 20, + $expand: ['orders'], + $count: true, + }); + + expect(query.$select).toEqual(['name', 'email']); + expect(query.$filter).toBe("country eq 'US' and revenue gt 100000"); + expect(query.$top).toBe(10); + expect(query.$count).toBe(true); + }); + }); +}); + +describe('ODataFilterOperatorSchema', () => { + it('should accept comparison operators', () => { + const operators = ['eq', 'ne', 'lt', 'le', 'gt', 'ge']; + + operators.forEach(op => { + expect(() => ODataFilterOperatorSchema.parse(op)).not.toThrow(); + }); + }); + + it('should accept logical operators', () => { + const operators = ['and', 'or', 'not']; + + operators.forEach(op => { + expect(() => ODataFilterOperatorSchema.parse(op)).not.toThrow(); + }); + }); + + it('should accept grouping operators', () => { + expect(() => ODataFilterOperatorSchema.parse('(')).not.toThrow(); + expect(() => ODataFilterOperatorSchema.parse(')')).not.toThrow(); + }); + + it('should accept other operators', () => { + expect(() => ODataFilterOperatorSchema.parse('in')).not.toThrow(); + expect(() => ODataFilterOperatorSchema.parse('has')).not.toThrow(); + }); + + it('should reject invalid operators', () => { + expect(() => ODataFilterOperatorSchema.parse('invalid')).toThrow(); + }); +}); + +describe('ODataFilterFunctionSchema', () => { + it('should accept string functions', () => { + const functions = [ + 'contains', 'startswith', 'endswith', 'length', + 'indexof', 'substring', 'tolower', 'toupper', 'trim', 'concat' + ]; + + functions.forEach(fn => { + expect(() => ODataFilterFunctionSchema.parse(fn)).not.toThrow(); + }); + }); + + it('should accept date/time functions', () => { + const functions = [ + 'year', 'month', 'day', 'hour', 'minute', 'second', + 'date', 'time', 'now', 'maxdatetime', 'mindatetime' + ]; + + functions.forEach(fn => { + expect(() => ODataFilterFunctionSchema.parse(fn)).not.toThrow(); + }); + }); + + it('should accept math functions', () => { + const functions = ['round', 'floor', 'ceiling']; + + functions.forEach(fn => { + expect(() => ODataFilterFunctionSchema.parse(fn)).not.toThrow(); + }); + }); + + it('should accept type functions', () => { + expect(() => ODataFilterFunctionSchema.parse('cast')).not.toThrow(); + expect(() => ODataFilterFunctionSchema.parse('isof')).not.toThrow(); + }); + + it('should accept collection functions', () => { + expect(() => ODataFilterFunctionSchema.parse('any')).not.toThrow(); + expect(() => ODataFilterFunctionSchema.parse('all')).not.toThrow(); + }); +}); + +describe('ODataResponseSchema', () => { + it('should accept basic response', () => { + const response = ODataResponseSchema.parse({ + value: [ + { id: '1', name: 'Item 1' }, + { id: '2', name: 'Item 2' }, + ], + }); + + expect(response.value).toHaveLength(2); + }); + + it('should accept response with context', () => { + const response = ODataResponseSchema.parse({ + '@odata.context': 'https://api.example.com/$metadata#Customers', + value: [{ id: '1', name: 'Customer 1' }], + }); + + expect(response['@odata.context']).toBeDefined(); + }); + + it('should accept response with count', () => { + const response = ODataResponseSchema.parse({ + '@odata.count': 100, + value: [{ id: '1' }], + }); + + expect(response['@odata.count']).toBe(100); + }); + + it('should accept response with nextLink', () => { + const response = ODataResponseSchema.parse({ + '@odata.nextLink': 'https://api.example.com/customers?$skip=10', + value: [{ id: '1' }], + }); + + expect(response['@odata.nextLink']).toBeDefined(); + }); + + it('should accept complete response', () => { + const response = ODataResponseSchema.parse({ + '@odata.context': 'https://api.example.com/$metadata#Customers', + '@odata.count': 100, + '@odata.nextLink': 'https://api.example.com/customers?$skip=10', + value: [ + { id: '1', name: 'Customer 1' }, + { id: '2', name: 'Customer 2' }, + ], + }); + + expect(response.value).toHaveLength(2); + expect(response['@odata.count']).toBe(100); + }); +}); + +describe('ODataErrorSchema', () => { + it('should accept basic error', () => { + const error = ODataErrorSchema.parse({ + error: { + code: 'validation_error', + message: 'Invalid input data', + }, + }); + + expect(error.error.code).toBe('validation_error'); + expect(error.error.message).toBe('Invalid input data'); + }); + + it('should accept error with target', () => { + const error = ODataErrorSchema.parse({ + error: { + code: 'invalid_field', + message: 'Field is required', + target: 'email', + }, + }); + + expect(error.error.target).toBe('email'); + }); + + it('should accept error with details', () => { + const error = ODataErrorSchema.parse({ + error: { + code: 'validation_error', + message: 'Multiple validation errors', + details: [ + { code: 'required', message: 'Email is required', target: 'email' }, + { code: 'min_length', message: 'Name too short', target: 'name' }, + ], + }, + }); + + expect(error.error.details).toHaveLength(2); + }); + + it('should accept error with inner error', () => { + const error = ODataErrorSchema.parse({ + error: { + code: 'server_error', + message: 'Internal error', + innererror: { + stackTrace: 'Error at line 123...', + requestId: 'req-abc-123', + }, + }, + }); + + expect(error.error.innererror).toBeDefined(); + }); +}); + +describe('ODataMetadataSchema', () => { + it('should accept basic metadata', () => { + const metadata = ODataMetadataSchema.parse({ + namespace: 'MyService', + entityTypes: [ + { + name: 'Customer', + key: ['id'], + properties: [ + { name: 'id', type: 'Edm.String' }, + { name: 'name', type: 'Edm.String' }, + ], + }, + ], + entitySets: [ + { name: 'Customers', entityType: 'Customer' }, + ], + }); + + expect(metadata.namespace).toBe('MyService'); + expect(metadata.entityTypes).toHaveLength(1); + expect(metadata.entitySets).toHaveLength(1); + }); + + it('should accept entity type with navigation properties', () => { + const metadata = ODataMetadataSchema.parse({ + namespace: 'MyService', + entityTypes: [ + { + name: 'Order', + key: ['id'], + properties: [ + { name: 'id', type: 'Edm.String' }, + { name: 'total', type: 'Edm.Decimal' }, + ], + navigationProperties: [ + { name: 'customer', type: 'Customer' }, + ], + }, + ], + entitySets: [ + { name: 'Orders', entityType: 'Order' }, + ], + }); + + expect(metadata.entityTypes[0].navigationProperties).toHaveLength(1); + }); + + it('should apply default nullable', () => { + const metadata = ODataMetadataSchema.parse({ + namespace: 'MyService', + entityTypes: [ + { + name: 'Product', + key: ['id'], + properties: [ + { name: 'id', type: 'Edm.String' }, + ], + }, + ], + entitySets: [ + { name: 'Products', entityType: 'Product' }, + ], + }); + + expect(metadata.entityTypes[0].properties[0].nullable).toBe(true); + }); +}); + +describe('OData Helper Functions', () => { + describe('buildUrl', () => { + it('should build URL with select', () => { + const url = OData.buildUrl('/api/customers', { + $select: 'name,email', + }); + + expect(url).toBe('/api/customers?%24select=name%2Cemail'); + }); + + it('should build URL with filter', () => { + const url = OData.buildUrl('/api/customers', { + $filter: "status eq 'active'", + }); + + expect(url).toContain('%24filter'); // URL-encoded $filter + }); + + it('should build URL with multiple parameters', () => { + const url = OData.buildUrl('/api/customers', { + $select: ['name', 'email'], + $top: 10, + $skip: 20, + $count: true, + }); + + expect(url).toContain('%24select'); // URL-encoded $select + expect(url).toContain('%24top'); + expect(url).toContain('%24skip'); + expect(url).toContain('%24count'); + }); + + it('should return base URL when no query parameters', () => { + const url = OData.buildUrl('/api/customers', {}); + + expect(url).toBe('/api/customers'); + }); + }); + + describe('filter helpers', () => { + it('should create eq filter', () => { + const filter = OData.filter.eq('status', 'active'); + expect(filter).toBe("status eq 'active'"); + }); + + it('should create ne filter', () => { + const filter = OData.filter.ne('status', 'inactive'); + expect(filter).toBe("status ne 'inactive'"); + }); + + it('should create gt filter', () => { + const filter = OData.filter.gt('revenue', 1000); + expect(filter).toBe('revenue gt 1000'); + }); + + it('should create lt filter', () => { + const filter = OData.filter.lt('age', 30); + expect(filter).toBe('age lt 30'); + }); + + it('should create contains filter', () => { + const filter = OData.filter.contains('name', 'Smith'); + expect(filter).toBe("contains(name, 'Smith')"); + }); + + it('should create and filter', () => { + const filter = OData.filter.and( + OData.filter.eq('country', 'US'), + OData.filter.gt('revenue', 100000) + ); + expect(filter).toBe("country eq 'US' and revenue gt 100000"); + }); + + it('should create or filter', () => { + const filter = OData.filter.or( + OData.filter.eq('status', 'active'), + OData.filter.eq('status', 'pending') + ); + expect(filter).toBe("status eq 'active' or status eq 'pending'"); + }); + + it('should handle number values', () => { + const filter = OData.filter.eq('count', 5); + expect(filter).toBe('count eq 5'); + }); + + it('should handle boolean values', () => { + const filter = OData.filter.eq('isActive', true); + expect(filter).toBe('isActive eq true'); + }); + }); +}); diff --git a/packages/spec/src/api/protocol.test.ts b/packages/spec/src/api/protocol.test.ts new file mode 100644 index 000000000..37dbaaf97 --- /dev/null +++ b/packages/spec/src/api/protocol.test.ts @@ -0,0 +1,645 @@ +import { describe, it, expect } from 'vitest'; +import { + GetDiscoveryRequestSchema, + GetDiscoveryResponseSchema, + GetMetaTypesRequestSchema, + GetMetaTypesResponseSchema, + GetMetaItemsRequestSchema, + GetMetaItemsResponseSchema, + GetMetaItemRequestSchema, + GetMetaItemResponseSchema, + GetUiViewRequestSchema, + GetUiViewResponseSchema, + FindDataRequestSchema, + FindDataResponseSchema, + GetDataRequestSchema, + GetDataResponseSchema, + CreateDataRequestSchema, + CreateDataResponseSchema, + UpdateDataRequestSchema, + UpdateDataResponseSchema, + DeleteDataRequestSchema, + DeleteDataResponseSchema, + BatchDataRequestSchema, + CreateManyDataRequestSchema, + CreateManyDataResponseSchema, + UpdateManyDataRequestSchema, + DeleteManyDataRequestSchema, + GetViewRequestSchema, + DeleteViewRequestSchema, + DeleteViewResponseSchema, +} from './protocol.zod'; + +describe('Discovery & Metadata Operations', () => { + describe('GetDiscoveryRequestSchema', () => { + it('should accept empty request', () => { + const request = GetDiscoveryRequestSchema.parse({}); + expect(request).toEqual({}); + }); + }); + + describe('GetDiscoveryResponseSchema', () => { + it('should accept basic response', () => { + const response = GetDiscoveryResponseSchema.parse({ + version: 'v1', + apiName: 'ObjectStack API', + }); + + expect(response.version).toBe('v1'); + expect(response.apiName).toBe('ObjectStack API'); + }); + + it('should accept response with capabilities', () => { + const response = GetDiscoveryResponseSchema.parse({ + version: 'v1', + apiName: 'ObjectStack API', + capabilities: ['crud', 'batch', 'realtime'], + }); + + expect(response.capabilities).toHaveLength(3); + }); + + it('should accept response with endpoints', () => { + const response = GetDiscoveryResponseSchema.parse({ + version: 'v1', + apiName: 'ObjectStack API', + endpoints: { + data: '/api/v1/data', + meta: '/api/v1/meta', + }, + }); + + expect(response.endpoints).toBeDefined(); + }); + }); + + describe('GetMetaTypesRequestSchema', () => { + it('should accept empty request', () => { + const request = GetMetaTypesRequestSchema.parse({}); + expect(request).toEqual({}); + }); + }); + + describe('GetMetaTypesResponseSchema', () => { + it('should accept metadata types', () => { + const response = GetMetaTypesResponseSchema.parse({ + types: ['object', 'plugin', 'view', 'flow'], + }); + + expect(response.types).toHaveLength(4); + }); + }); + + describe('GetMetaItemsRequestSchema', () => { + it('should accept type parameter', () => { + const request = GetMetaItemsRequestSchema.parse({ + type: 'object', + }); + + expect(request.type).toBe('object'); + }); + + it('should reject request without type', () => { + expect(() => GetMetaItemsRequestSchema.parse({})).toThrow(); + }); + }); + + describe('GetMetaItemsResponseSchema', () => { + it('should accept items response', () => { + const response = GetMetaItemsResponseSchema.parse({ + type: 'object', + items: [ + { name: 'account', label: 'Account' }, + { name: 'contact', label: 'Contact' }, + ], + }); + + expect(response.type).toBe('object'); + expect(response.items).toHaveLength(2); + }); + + it('should accept empty items', () => { + const response = GetMetaItemsResponseSchema.parse({ + type: 'object', + items: [], + }); + + expect(response.items).toHaveLength(0); + }); + }); + + describe('GetMetaItemRequestSchema', () => { + it('should accept type and name', () => { + const request = GetMetaItemRequestSchema.parse({ + type: 'object', + name: 'account', + }); + + expect(request.type).toBe('object'); + expect(request.name).toBe('account'); + }); + + it('should reject request without type', () => { + expect(() => GetMetaItemRequestSchema.parse({ + name: 'account', + })).toThrow(); + }); + + it('should reject request without name', () => { + expect(() => GetMetaItemRequestSchema.parse({ + type: 'object', + })).toThrow(); + }); + }); + + describe('GetMetaItemResponseSchema', () => { + it('should accept item response', () => { + const response = GetMetaItemResponseSchema.parse({ + type: 'object', + name: 'account', + item: { + name: 'account', + label: 'Account', + fields: {}, + }, + }); + + expect(response.type).toBe('object'); + expect(response.name).toBe('account'); + expect(response.item).toBeDefined(); + }); + }); + + describe('GetUiViewRequestSchema', () => { + it('should accept list view request', () => { + const request = GetUiViewRequestSchema.parse({ + object: 'account', + type: 'list', + }); + + expect(request.object).toBe('account'); + expect(request.type).toBe('list'); + }); + + it('should accept form view request', () => { + const request = GetUiViewRequestSchema.parse({ + object: 'contact', + type: 'form', + }); + + expect(request.type).toBe('form'); + }); + + it('should reject invalid view type', () => { + expect(() => GetUiViewRequestSchema.parse({ + object: 'account', + type: 'invalid', + })).toThrow(); + }); + }); + + describe('GetUiViewResponseSchema', () => { + it('should accept view response', () => { + const response = GetUiViewResponseSchema.parse({ + object: 'account', + type: 'list', + view: { + columns: ['name', 'email'], + }, + }); + + expect(response.object).toBe('account'); + expect(response.type).toBe('list'); + expect(response.view).toBeDefined(); + }); + }); +}); + +describe('Data Operations', () => { + describe('FindDataRequestSchema', () => { + it('should accept basic find request', () => { + const request = FindDataRequestSchema.parse({ + object: 'account', + }); + + expect(request.object).toBe('account'); + }); + + it('should accept find with query', () => { + const request = FindDataRequestSchema.parse({ + object: 'account', + query: { + object: 'account', + where: { status: 'active' }, + limit: 10, + }, + }); + + expect(request.query).toBeDefined(); + }); + + it('should reject request without object', () => { + expect(() => FindDataRequestSchema.parse({})).toThrow(); + }); + }); + + describe('FindDataResponseSchema', () => { + it('should accept find response', () => { + const response = FindDataResponseSchema.parse({ + object: 'account', + records: [ + { id: '1', name: 'Account 1' }, + { id: '2', name: 'Account 2' }, + ], + }); + + expect(response.object).toBe('account'); + expect(response.records).toHaveLength(2); + }); + + it('should accept response with total', () => { + const response = FindDataResponseSchema.parse({ + object: 'account', + records: [], + total: 100, + hasMore: true, + }); + + expect(response.total).toBe(100); + expect(response.hasMore).toBe(true); + }); + }); + + describe('GetDataRequestSchema', () => { + it('should accept get request', () => { + const request = GetDataRequestSchema.parse({ + object: 'account', + id: '123', + }); + + expect(request.object).toBe('account'); + expect(request.id).toBe('123'); + }); + + it('should reject request without id', () => { + expect(() => GetDataRequestSchema.parse({ + object: 'account', + })).toThrow(); + }); + }); + + describe('GetDataResponseSchema', () => { + it('should accept get response', () => { + const response = GetDataResponseSchema.parse({ + object: 'account', + id: '123', + record: { id: '123', name: 'Account 1' }, + }); + + expect(response.id).toBe('123'); + expect(response.record).toBeDefined(); + }); + }); + + describe('CreateDataRequestSchema', () => { + it('should accept create request', () => { + const request = CreateDataRequestSchema.parse({ + object: 'account', + data: { name: 'New Account', industry: 'Technology' }, + }); + + expect(request.object).toBe('account'); + expect(request.data.name).toBe('New Account'); + }); + + it('should accept create with nested data', () => { + const request = CreateDataRequestSchema.parse({ + object: 'contact', + data: { + first_name: 'John', + last_name: 'Doe', + address: { + street: '123 Main St', + city: 'New York', + }, + }, + }); + + expect(request.data.address).toBeDefined(); + }); + }); + + describe('CreateDataResponseSchema', () => { + it('should accept create response', () => { + const response = CreateDataResponseSchema.parse({ + object: 'account', + id: '123', + record: { id: '123', name: 'New Account' }, + }); + + expect(response.id).toBe('123'); + expect(response.record).toBeDefined(); + }); + }); + + describe('UpdateDataRequestSchema', () => { + it('should accept update request', () => { + const request = UpdateDataRequestSchema.parse({ + object: 'account', + id: '123', + data: { status: 'active' }, + }); + + expect(request.object).toBe('account'); + expect(request.id).toBe('123'); + expect(request.data.status).toBe('active'); + }); + + it('should accept partial update', () => { + const request = UpdateDataRequestSchema.parse({ + object: 'contact', + id: '456', + data: { email: 'updated@example.com' }, + }); + + expect(request.data.email).toBe('updated@example.com'); + }); + }); + + describe('UpdateDataResponseSchema', () => { + it('should accept update response', () => { + const response = UpdateDataResponseSchema.parse({ + object: 'account', + id: '123', + record: { id: '123', status: 'active' }, + }); + + expect(response.id).toBe('123'); + expect(response.record).toBeDefined(); + }); + }); + + describe('DeleteDataRequestSchema', () => { + it('should accept delete request', () => { + const request = DeleteDataRequestSchema.parse({ + object: 'account', + id: '123', + }); + + expect(request.object).toBe('account'); + expect(request.id).toBe('123'); + }); + }); + + describe('DeleteDataResponseSchema', () => { + it('should accept delete response', () => { + const response = DeleteDataResponseSchema.parse({ + object: 'account', + id: '123', + success: true, + }); + + expect(response.success).toBe(true); + expect(response.id).toBe('123'); + }); + + it('should accept failed delete', () => { + const response = DeleteDataResponseSchema.parse({ + object: 'account', + id: '123', + success: false, + }); + + expect(response.success).toBe(false); + }); + }); +}); + +describe('Batch Operations', () => { + describe('BatchDataRequestSchema', () => { + it('should accept batch request', () => { + const request = BatchDataRequestSchema.parse({ + object: 'account', + request: { + operation: 'update', + records: [ + { id: '1', data: { status: 'active' } }, + { id: '2', data: { status: 'inactive' } }, + ], + }, + }); + + expect(request.object).toBe('account'); + expect(request.request).toBeDefined(); + }); + }); + + describe('CreateManyDataRequestSchema', () => { + it('should accept create many request', () => { + const request = CreateManyDataRequestSchema.parse({ + object: 'contact', + records: [ + { first_name: 'John', last_name: 'Doe' }, + { first_name: 'Jane', last_name: 'Smith' }, + ], + }); + + expect(request.object).toBe('contact'); + expect(request.records).toHaveLength(2); + }); + + it('should accept empty records array', () => { + const request = CreateManyDataRequestSchema.parse({ + object: 'contact', + records: [], + }); + + expect(request.records).toHaveLength(0); + }); + }); + + describe('CreateManyDataResponseSchema', () => { + it('should accept create many response', () => { + const response = CreateManyDataResponseSchema.parse({ + object: 'contact', + records: [ + { id: '1', first_name: 'John' }, + { id: '2', first_name: 'Jane' }, + ], + count: 2, + }); + + expect(response.count).toBe(2); + expect(response.records).toHaveLength(2); + }); + }); + + describe('UpdateManyDataRequestSchema', () => { + it('should accept update many request', () => { + const request = UpdateManyDataRequestSchema.parse({ + object: 'account', + records: [ + { id: '1', data: { status: 'active' } }, + { id: '2', data: { status: 'inactive' } }, + ], + }); + + expect(request.records).toHaveLength(2); + }); + + it('should accept update many with options', () => { + const request = UpdateManyDataRequestSchema.parse({ + object: 'account', + records: [ + { id: '1', data: { status: 'active' } }, + ], + options: { + allOrNone: true, + }, + }); + + expect(request.options).toBeDefined(); + }); + }); + + describe('DeleteManyDataRequestSchema', () => { + it('should accept delete many request', () => { + const request = DeleteManyDataRequestSchema.parse({ + object: 'account', + ids: ['1', '2', '3'], + }); + + expect(request.ids).toHaveLength(3); + }); + + it('should accept delete many with options', () => { + const request = DeleteManyDataRequestSchema.parse({ + object: 'account', + ids: ['1', '2'], + options: { + allOrNone: false, + }, + }); + + expect(request.options).toBeDefined(); + }); + }); +}); + +describe('View Storage Operations', () => { + describe('GetViewRequestSchema', () => { + it('should accept get view request', () => { + const request = GetViewRequestSchema.parse({ + id: 'view_123', + }); + + expect(request.id).toBe('view_123'); + }); + + it('should reject request without id', () => { + expect(() => GetViewRequestSchema.parse({})).toThrow(); + }); + }); + + describe('DeleteViewRequestSchema', () => { + it('should accept delete view request', () => { + const request = DeleteViewRequestSchema.parse({ + id: 'view_123', + }); + + expect(request.id).toBe('view_123'); + }); + }); + + describe('DeleteViewResponseSchema', () => { + it('should accept successful delete', () => { + const response = DeleteViewResponseSchema.parse({ + success: true, + }); + + expect(response.success).toBe(true); + }); + + it('should accept failed delete', () => { + const response = DeleteViewResponseSchema.parse({ + success: false, + }); + + expect(response.success).toBe(false); + }); + }); +}); + +describe('Integration Tests', () => { + it('should support complete data workflow', () => { + // Create + const createRequest = CreateDataRequestSchema.parse({ + object: 'account', + data: { name: 'Test Account', industry: 'Technology' }, + }); + + const createResponse = CreateDataResponseSchema.parse({ + object: 'account', + id: '123', + record: { id: '123', name: 'Test Account', industry: 'Technology' }, + }); + + // Find + const findRequest = FindDataRequestSchema.parse({ + object: 'account', + query: { + object: 'account', + where: { industry: 'Technology' }, + }, + }); + + // Update + const updateRequest = UpdateDataRequestSchema.parse({ + object: 'account', + id: '123', + data: { status: 'active' }, + }); + + // Delete + const deleteRequest = DeleteDataRequestSchema.parse({ + object: 'account', + id: '123', + }); + + expect(createRequest.object).toBe('account'); + expect(createResponse.id).toBe('123'); + expect(findRequest.object).toBe('account'); + expect(updateRequest.id).toBe('123'); + expect(deleteRequest.id).toBe('123'); + }); + + it('should support metadata discovery workflow', () => { + // Get types + const typesResponse = GetMetaTypesResponseSchema.parse({ + types: ['object', 'view', 'flow'], + }); + + // Get items + const itemsRequest = GetMetaItemsRequestSchema.parse({ + type: 'object', + }); + + const itemsResponse = GetMetaItemsResponseSchema.parse({ + type: 'object', + items: [ + { name: 'account', label: 'Account' }, + { name: 'contact', label: 'Contact' }, + ], + }); + + // Get specific item + const itemRequest = GetMetaItemRequestSchema.parse({ + type: 'object', + name: 'account', + }); + + expect(typesResponse.types).toHaveLength(3); + expect(itemsResponse.items).toHaveLength(2); + expect(itemRequest.name).toBe('account'); + }); +}); diff --git a/packages/spec/src/api/rest-server.test.ts b/packages/spec/src/api/rest-server.test.ts new file mode 100644 index 000000000..943aaecac --- /dev/null +++ b/packages/spec/src/api/rest-server.test.ts @@ -0,0 +1,649 @@ +import { describe, it, expect } from 'vitest'; +import { + RestApiConfigSchema, + CrudOperation, + CrudEndpointPatternSchema, + CrudEndpointsConfigSchema, + MetadataEndpointsConfigSchema, + BatchEndpointsConfigSchema, + RouteGenerationConfigSchema, + RestServerConfigSchema, + GeneratedEndpointSchema, + EndpointRegistrySchema, + RestApiConfig, + RestServerConfig, + type RestApiConfig as RestApiConfigType, + type RestServerConfig as RestServerConfigType, +} from './rest-server.zod'; + +describe('RestApiConfigSchema', () => { + it('should accept minimal config with defaults', () => { + const config = RestApiConfigSchema.parse({}); + + expect(config.version).toBe('v1'); + expect(config.basePath).toBe('/api'); + expect(config.enableCrud).toBe(true); + expect(config.enableMetadata).toBe(true); + expect(config.enableBatch).toBe(true); + expect(config.enableDiscovery).toBe(true); + }); + + it('should accept custom version', () => { + const config = RestApiConfigSchema.parse({ + version: 'v2', + }); + + expect(config.version).toBe('v2'); + }); + + it('should accept date-based version', () => { + const config = RestApiConfigSchema.parse({ + version: '2024-01', + }); + + expect(config.version).toBe('2024-01'); + }); + + it('should accept custom basePath', () => { + const config = RestApiConfigSchema.parse({ + basePath: '/rest', + }); + + expect(config.basePath).toBe('/rest'); + }); + + it('should accept custom apiPath', () => { + const config = RestApiConfigSchema.parse({ + apiPath: '/api/v2', + }); + + expect(config.apiPath).toBe('/api/v2'); + }); + + it('should disable features', () => { + const config = RestApiConfigSchema.parse({ + enableCrud: false, + enableMetadata: false, + enableBatch: false, + enableDiscovery: false, + }); + + expect(config.enableCrud).toBe(false); + expect(config.enableMetadata).toBe(false); + expect(config.enableBatch).toBe(false); + expect(config.enableDiscovery).toBe(false); + }); + + describe('Documentation Configuration', () => { + it('should accept basic documentation config', () => { + const config = RestApiConfigSchema.parse({ + documentation: { + enabled: true, + title: 'My API', + }, + }); + + expect(config.documentation?.enabled).toBe(true); + expect(config.documentation?.title).toBe('My API'); + }); + + it('should accept complete documentation config', () => { + const config = RestApiConfigSchema.parse({ + documentation: { + enabled: true, + title: 'ObjectStack API', + description: 'Complete API for ObjectStack platform', + version: '1.0.0', + termsOfService: 'https://example.com/terms', + contact: { + name: 'API Support', + url: 'https://example.com/support', + email: 'api@example.com', + }, + license: { + name: 'MIT', + url: 'https://opensource.org/licenses/MIT', + }, + }, + }); + + expect(config.documentation?.title).toBe('ObjectStack API'); + expect(config.documentation?.contact?.email).toBe('api@example.com'); + expect(config.documentation?.license?.name).toBe('MIT'); + }); + }); + + describe('Response Format Configuration', () => { + it('should accept response format config', () => { + const config = RestApiConfigSchema.parse({ + responseFormat: { + envelope: true, + includeMetadata: true, + includePagination: true, + }, + }); + + expect(config.responseFormat?.envelope).toBe(true); + expect(config.responseFormat?.includeMetadata).toBe(true); + expect(config.responseFormat?.includePagination).toBe(true); + }); + + it('should accept minimal response format', () => { + const config = RestApiConfigSchema.parse({ + responseFormat: { + envelope: false, + includeMetadata: false, + includePagination: false, + }, + }); + + expect(config.responseFormat?.envelope).toBe(false); + }); + }); +}); + +describe('CrudOperation', () => { + it('should accept all CRUD operations', () => { + const operations = ['create', 'read', 'update', 'delete', 'list']; + + operations.forEach(op => { + expect(() => CrudOperation.parse(op)).not.toThrow(); + }); + }); + + it('should reject invalid operation', () => { + expect(() => CrudOperation.parse('invalid')).toThrow(); + }); +}); + +describe('CrudEndpointPatternSchema', () => { + it('should accept basic pattern', () => { + const pattern = CrudEndpointPatternSchema.parse({ + method: 'GET', + path: '/data/{object}', + }); + + expect(pattern.method).toBe('GET'); + expect(pattern.path).toBe('/data/{object}'); + }); + + it('should accept pattern with documentation', () => { + const pattern = CrudEndpointPatternSchema.parse({ + method: 'POST', + path: '/data/{object}', + summary: 'Create record', + description: 'Creates a new record in the specified object', + }); + + expect(pattern.summary).toBe('Create record'); + expect(pattern.description).toBeDefined(); + }); +}); + +describe('CrudEndpointsConfigSchema', () => { + it('should accept default config', () => { + const config = CrudEndpointsConfigSchema.parse({}); + + expect(config.dataPrefix).toBe('/data'); + expect(config.objectParamStyle).toBe('path'); + }); + + it('should accept custom operations config', () => { + const config = CrudEndpointsConfigSchema.parse({ + operations: { + create: true, + read: true, + update: true, + delete: false, + list: true, + }, + }); + + expect(config.operations?.delete).toBe(false); + }); + + it('should accept custom patterns', () => { + const config = CrudEndpointsConfigSchema.parse({ + patterns: { + create: { method: 'POST', path: '/objects/{object}' }, + read: { method: 'GET', path: '/objects/{object}/:id' }, + }, + }); + + expect(config.patterns?.create.path).toBe('/objects/{object}'); + }); + + it('should accept custom data prefix', () => { + const config = CrudEndpointsConfigSchema.parse({ + dataPrefix: '/objects', + }); + + expect(config.dataPrefix).toBe('/objects'); + }); + + it('should accept query param style', () => { + const config = CrudEndpointsConfigSchema.parse({ + objectParamStyle: 'query', + }); + + expect(config.objectParamStyle).toBe('query'); + }); +}); + +describe('MetadataEndpointsConfigSchema', () => { + it('should accept default config', () => { + const config = MetadataEndpointsConfigSchema.parse({}); + + expect(config.prefix).toBe('/meta'); + expect(config.enableCache).toBe(true); + expect(config.cacheTtl).toBe(3600); + }); + + it('should accept custom prefix', () => { + const config = MetadataEndpointsConfigSchema.parse({ + prefix: '/metadata', + }); + + expect(config.prefix).toBe('/metadata'); + }); + + it('should accept cache config', () => { + const config = MetadataEndpointsConfigSchema.parse({ + enableCache: false, + cacheTtl: 7200, + }); + + expect(config.enableCache).toBe(false); + expect(config.cacheTtl).toBe(7200); + }); + + it('should accept endpoints config', () => { + const config = MetadataEndpointsConfigSchema.parse({ + endpoints: { + types: true, + items: true, + item: true, + schema: false, + }, + }); + + expect(config.endpoints?.schema).toBe(false); + }); +}); + +describe('BatchEndpointsConfigSchema', () => { + it('should accept default config', () => { + const config = BatchEndpointsConfigSchema.parse({}); + + expect(config.maxBatchSize).toBe(200); + expect(config.enableBatchEndpoint).toBe(true); + expect(config.defaultAtomic).toBe(true); + }); + + it('should accept custom max batch size', () => { + const config = BatchEndpointsConfigSchema.parse({ + maxBatchSize: 500, + }); + + expect(config.maxBatchSize).toBe(500); + }); + + it('should reject invalid batch size', () => { + expect(() => BatchEndpointsConfigSchema.parse({ + maxBatchSize: 0, + })).toThrow(); + + expect(() => BatchEndpointsConfigSchema.parse({ + maxBatchSize: 2000, + })).toThrow(); + }); + + it('should accept operations config', () => { + const config = BatchEndpointsConfigSchema.parse({ + operations: { + createMany: true, + updateMany: true, + deleteMany: false, + upsertMany: true, + }, + }); + + expect(config.operations?.deleteMany).toBe(false); + }); + + it('should accept non-atomic mode', () => { + const config = BatchEndpointsConfigSchema.parse({ + defaultAtomic: false, + }); + + expect(config.defaultAtomic).toBe(false); + }); +}); + +describe('RouteGenerationConfigSchema', () => { + it('should accept minimal config', () => { + const config = RouteGenerationConfigSchema.parse({}); + + expect(config.nameTransform).toBe('none'); + }); + + it('should accept include objects', () => { + const config = RouteGenerationConfigSchema.parse({ + includeObjects: ['account', 'contact', 'opportunity'], + }); + + expect(config.includeObjects).toHaveLength(3); + }); + + it('should accept exclude objects', () => { + const config = RouteGenerationConfigSchema.parse({ + excludeObjects: ['system_log', 'audit_trail'], + }); + + expect(config.excludeObjects).toHaveLength(2); + }); + + it('should accept name transform', () => { + const transforms = ['none', 'plural', 'kebab-case', 'camelCase'] as const; + + transforms.forEach(transform => { + const config = RouteGenerationConfigSchema.parse({ + nameTransform: transform, + }); + expect(config.nameTransform).toBe(transform); + }); + }); + + it('should accept overrides', () => { + const config = RouteGenerationConfigSchema.parse({ + overrides: { + account: { + enabled: true, + basePath: '/accounts', + }, + contact: { + enabled: false, + }, + task: { + operations: { + create: true, + read: true, + update: true, + delete: false, + list: true, + }, + }, + }, + }); + + expect(config.overrides?.account?.basePath).toBe('/accounts'); + expect(config.overrides?.contact?.enabled).toBe(false); + expect(config.overrides?.task?.operations?.delete).toBe(false); + }); +}); + +describe('RestServerConfigSchema', () => { + it('should accept minimal config', () => { + const config = RestServerConfigSchema.parse({}); + + expect(config).toBeDefined(); + }); + + it('should accept complete config', () => { + const config = RestServerConfigSchema.parse({ + api: { + version: 'v1', + basePath: '/api', + enableCrud: true, + enableMetadata: true, + enableBatch: true, + }, + crud: { + dataPrefix: '/data', + operations: { + create: true, + read: true, + update: true, + delete: true, + list: true, + }, + }, + metadata: { + prefix: '/meta', + enableCache: true, + }, + batch: { + maxBatchSize: 200, + }, + routes: { + excludeObjects: ['system_log'], + }, + }); + + expect(config.api?.version).toBe('v1'); + expect(config.crud?.dataPrefix).toBe('/data'); + expect(config.metadata?.prefix).toBe('/meta'); + expect(config.batch?.maxBatchSize).toBe(200); + expect(config.routes?.excludeObjects).toContain('system_log'); + }); +}); + +describe('GeneratedEndpointSchema', () => { + it('should accept basic endpoint', () => { + const endpoint = GeneratedEndpointSchema.parse({ + id: 'list_accounts', + method: 'GET', + path: '/api/v1/data/account', + object: 'account', + operation: 'list', + handler: 'list_handler', + }); + + expect(endpoint.id).toBe('list_accounts'); + expect(endpoint.method).toBe('GET'); + expect(endpoint.operation).toBe('list'); + }); + + it('should accept endpoint with metadata', () => { + const endpoint = GeneratedEndpointSchema.parse({ + id: 'create_account', + method: 'POST', + path: '/api/v1/data/account', + object: 'account', + operation: 'create', + handler: 'create_handler', + metadata: { + summary: 'Create Account', + description: 'Creates a new account record', + tags: ['account', 'crm'], + deprecated: false, + }, + }); + + expect(endpoint.metadata?.summary).toBe('Create Account'); + expect(endpoint.metadata?.tags).toContain('crm'); + }); +}); + +describe('EndpointRegistrySchema', () => { + it('should accept basic registry', () => { + const registry = EndpointRegistrySchema.parse({ + endpoints: [ + { + id: 'list_accounts', + method: 'GET', + path: '/api/v1/data/account', + object: 'account', + operation: 'list', + handler: 'list_handler', + }, + ], + total: 1, + }); + + expect(registry.endpoints).toHaveLength(1); + expect(registry.total).toBe(1); + }); + + it('should accept registry with groupings', () => { + const registry = EndpointRegistrySchema.parse({ + endpoints: [ + { + id: 'list_accounts', + method: 'GET', + path: '/api/data/account', + object: 'account', + operation: 'list', + handler: 'list_handler', + }, + { + id: 'create_account', + method: 'POST', + path: '/api/data/account', + object: 'account', + operation: 'create', + handler: 'create_handler', + }, + ], + total: 2, + byObject: { + account: [ + { + id: 'list_accounts', + method: 'GET', + path: '/api/data/account', + object: 'account', + operation: 'list', + handler: 'list_handler', + }, + { + id: 'create_account', + method: 'POST', + path: '/api/data/account', + object: 'account', + operation: 'create', + handler: 'create_handler', + }, + ], + }, + byOperation: { + list: [ + { + id: 'list_accounts', + method: 'GET', + path: '/api/data/account', + object: 'account', + operation: 'list', + handler: 'list_handler', + }, + ], + create: [ + { + id: 'create_account', + method: 'POST', + path: '/api/data/account', + object: 'account', + operation: 'create', + handler: 'create_handler', + }, + ], + }, + }); + + expect(registry.byObject?.account).toHaveLength(2); + expect(registry.byOperation?.list).toHaveLength(1); + }); +}); + +describe('Helper Functions', () => { + it('should create config with RestApiConfig.create', () => { + const config = RestApiConfig.create({ + version: 'v2', + basePath: '/api', + }); + + expect(config.version).toBe('v2'); + }); + + it('should create server config with RestServerConfig.create', () => { + const config = RestServerConfig.create({ + api: { + version: 'v1', + }, + crud: { + dataPrefix: '/data', + }, + }); + + expect(config.api?.version).toBe('v1'); + }); +}); + +describe('Integration Tests', () => { + it('should support complete REST server configuration', () => { + const serverConfig: RestServerConfigType = { + api: { + version: 'v1', + basePath: '/api', + enableCrud: true, + enableMetadata: true, + enableBatch: true, + enableDiscovery: true, + documentation: { + enabled: true, + title: 'ObjectStack API', + description: 'REST API for ObjectStack platform', + version: '1.0.0', + }, + responseFormat: { + envelope: true, + includeMetadata: true, + includePagination: true, + }, + }, + crud: { + dataPrefix: '/data', + operations: { + create: true, + read: true, + update: true, + delete: true, + list: true, + }, + objectParamStyle: 'path', + }, + metadata: { + prefix: '/meta', + enableCache: true, + cacheTtl: 3600, + endpoints: { + types: true, + items: true, + item: true, + schema: true, + }, + }, + batch: { + maxBatchSize: 200, + enableBatchEndpoint: true, + operations: { + createMany: true, + updateMany: true, + deleteMany: true, + upsertMany: true, + }, + defaultAtomic: true, + }, + routes: { + excludeObjects: ['system_log'], + nameTransform: 'none', + }, + }; + + const result = RestServerConfigSchema.parse(serverConfig); + expect(result.api?.version).toBe('v1'); + expect(result.crud?.dataPrefix).toBe('/data'); + expect(result.metadata?.cacheTtl).toBe(3600); + expect(result.batch?.maxBatchSize).toBe(200); + }); +}); diff --git a/packages/spec/src/api/router.test.ts b/packages/spec/src/api/router.test.ts new file mode 100644 index 000000000..0f9c7e302 --- /dev/null +++ b/packages/spec/src/api/router.test.ts @@ -0,0 +1,557 @@ +import { describe, it, expect } from 'vitest'; +import { + RouteCategory, + RouteDefinitionSchema, + RouterConfigSchema, + HttpMethod, + type RouteDefinition, + type RouterConfig, +} from './router.zod'; + +describe('RouteCategory', () => { + it('should accept valid route categories', () => { + const categories = ['system', 'api', 'auth', 'static', 'webhook', 'plugin']; + + categories.forEach(category => { + expect(() => RouteCategory.parse(category)).not.toThrow(); + }); + }); + + it('should reject invalid category', () => { + expect(() => RouteCategory.parse('invalid')).toThrow(); + }); +}); + +describe('RouteDefinitionSchema', () => { + describe('Basic Route Properties', () => { + it('should accept minimal valid route', () => { + const route = RouteDefinitionSchema.parse({ + method: 'GET', + path: '/api/test', + handler: 'test_handler', + }); + + expect(route.method).toBe('GET'); + expect(route.path).toBe('/api/test'); + expect(route.handler).toBe('test_handler'); + }); + + it('should apply default category', () => { + const route = RouteDefinitionSchema.parse({ + method: 'GET', + path: '/api/test', + handler: 'test_handler', + }); + + expect(route.category).toBe('api'); + }); + + it('should apply default public flag', () => { + const route = RouteDefinitionSchema.parse({ + method: 'GET', + path: '/api/test', + handler: 'test_handler', + }); + + expect(route.public).toBe(false); + }); + + it('should accept custom category', () => { + const route = RouteDefinitionSchema.parse({ + method: 'GET', + path: '/health', + handler: 'health_check', + category: 'system', + }); + + expect(route.category).toBe('system'); + }); + }); + + describe('HTTP Methods', () => { + it('should accept all HTTP methods', () => { + const methods: Array> = ['GET', 'POST', 'PUT', 'DELETE', 'PATCH', 'HEAD', 'OPTIONS']; + + methods.forEach(method => { + const route = RouteDefinitionSchema.parse({ + method, + path: '/api/test', + handler: 'test_handler', + }); + expect(route.method).toBe(method); + }); + }); + }); + + describe('Path Patterns', () => { + it('should accept path with parameters', () => { + const route = RouteDefinitionSchema.parse({ + method: 'GET', + path: '/api/users/:id', + handler: 'get_user', + }); + + expect(route.path).toBe('/api/users/:id'); + }); + + it('should accept path with multiple parameters', () => { + const route = RouteDefinitionSchema.parse({ + method: 'GET', + path: '/api/projects/:projectId/tasks/:taskId', + handler: 'get_task', + }); + + expect(route.path).toBe('/api/projects/:projectId/tasks/:taskId'); + }); + + it('should accept root path', () => { + const route = RouteDefinitionSchema.parse({ + method: 'GET', + path: '/', + handler: 'root_handler', + }); + + expect(route.path).toBe('/'); + }); + + it('should accept wildcard path', () => { + const route = RouteDefinitionSchema.parse({ + method: 'GET', + path: '/api/*', + handler: 'catch_all', + }); + + expect(route.path).toBe('/api/*'); + }); + }); + + describe('Documentation', () => { + it('should accept route with summary', () => { + const route = RouteDefinitionSchema.parse({ + method: 'GET', + path: '/api/users', + handler: 'list_users', + summary: 'List all users', + }); + + expect(route.summary).toBe('List all users'); + }); + + it('should accept route with description', () => { + const route = RouteDefinitionSchema.parse({ + method: 'POST', + path: '/api/users', + handler: 'create_user', + summary: 'Create user', + description: 'Creates a new user with the provided data', + }); + + expect(route.description).toBe('Creates a new user with the provided data'); + }); + }); + + describe('Security', () => { + it('should accept public route', () => { + const route = RouteDefinitionSchema.parse({ + method: 'GET', + path: '/api/public', + handler: 'public_handler', + public: true, + }); + + expect(route.public).toBe(true); + }); + + it('should accept route with permissions', () => { + const route = RouteDefinitionSchema.parse({ + method: 'DELETE', + path: '/api/users/:id', + handler: 'delete_user', + permissions: ['users.delete', 'admin'], + }); + + expect(route.permissions).toHaveLength(2); + expect(route.permissions).toContain('users.delete'); + }); + + it('should accept private route with permissions', () => { + const route = RouteDefinitionSchema.parse({ + method: 'POST', + path: '/api/admin/settings', + handler: 'update_settings', + public: false, + permissions: ['admin'], + }); + + expect(route.public).toBe(false); + expect(route.permissions).toContain('admin'); + }); + }); + + describe('Performance', () => { + it('should accept route with timeout', () => { + const route = RouteDefinitionSchema.parse({ + method: 'POST', + path: '/api/batch', + handler: 'batch_process', + timeout: 30000, + }); + + expect(route.timeout).toBe(30000); + }); + + it('should accept route with rate limit', () => { + const route = RouteDefinitionSchema.parse({ + method: 'POST', + path: '/api/upload', + handler: 'upload_file', + rateLimit: 'strict', + }); + + expect(route.rateLimit).toBe('strict'); + }); + + it('should accept route with timeout and rate limit', () => { + const route = RouteDefinitionSchema.parse({ + method: 'POST', + path: '/api/heavy-operation', + handler: 'heavy_handler', + timeout: 60000, + rateLimit: 'moderate', + }); + + expect(route.timeout).toBe(60000); + expect(route.rateLimit).toBe('moderate'); + }); + }); + + describe('Complete Route Examples', () => { + it('should accept system health check route', () => { + const route: RouteDefinition = { + method: 'GET', + path: '/health', + category: 'system', + handler: 'health_check', + summary: 'Health Check', + public: true, + }; + + expect(() => RouteDefinitionSchema.parse(route)).not.toThrow(); + }); + + it('should accept authenticated API route', () => { + const route: RouteDefinition = { + method: 'POST', + path: '/api/v1/orders', + category: 'api', + handler: 'create_order', + summary: 'Create Order', + description: 'Creates a new order in the system', + public: false, + permissions: ['orders.create'], + timeout: 5000, + }; + + expect(() => RouteDefinitionSchema.parse(route)).not.toThrow(); + }); + + it('should accept webhook route', () => { + const route: RouteDefinition = { + method: 'POST', + path: '/webhooks/stripe', + category: 'webhook', + handler: 'stripe_webhook', + summary: 'Stripe Webhook', + public: true, + }; + + expect(() => RouteDefinitionSchema.parse(route)).not.toThrow(); + }); + + it('should accept static file route', () => { + const route: RouteDefinition = { + method: 'GET', + path: '/static/*', + category: 'static', + handler: 'serve_static', + public: true, + }; + + expect(() => RouteDefinitionSchema.parse(route)).not.toThrow(); + }); + + it('should accept plugin route', () => { + const route: RouteDefinition = { + method: 'GET', + path: '/plugins/custom-report', + category: 'plugin', + handler: 'custom_report_handler', + summary: 'Custom Report', + permissions: ['reports.view'], + }; + + expect(() => RouteDefinitionSchema.parse(route)).not.toThrow(); + }); + }); +}); + +describe('RouterConfigSchema', () => { + describe('Basic Configuration', () => { + it('should accept minimal config with defaults', () => { + const config = RouterConfigSchema.parse({}); + + expect(config.basePath).toBe('/api'); + expect(config.mounts).toBeDefined(); + }); + + it('should apply default basePath', () => { + const config = RouterConfigSchema.parse({}); + + expect(config.basePath).toBe('/api'); + }); + + it('should accept custom basePath', () => { + const config = RouterConfigSchema.parse({ + basePath: '/v1', + }); + + expect(config.basePath).toBe('/v1'); + }); + }); + + describe('Protocol Mounts', () => { + it('should apply default mounts', () => { + const config = RouterConfigSchema.parse({}); + + expect(config.mounts.data).toBe('/data'); + expect(config.mounts.metadata).toBe('/meta'); + expect(config.mounts.auth).toBe('/auth'); + expect(config.mounts.automation).toBe('/automation'); + expect(config.mounts.storage).toBe('/storage'); + expect(config.mounts.graphql).toBe('/graphql'); + }); + + it('should accept custom mounts', () => { + const config = RouterConfigSchema.parse({ + mounts: { + data: '/api/data', + metadata: '/api/metadata', + auth: '/api/auth', + automation: '/api/automation', + storage: '/api/storage', + graphql: '/api/graphql', + }, + }); + + expect(config.mounts.data).toBe('/api/data'); + expect(config.mounts.metadata).toBe('/api/metadata'); + }); + + it('should merge custom mounts with defaults', () => { + const config = RouterConfigSchema.parse({ + mounts: { + data: '/custom-data', + }, + }); + + expect(config.mounts.data).toBe('/custom-data'); + expect(config.mounts.metadata).toBe('/meta'); // default + }); + }); + + describe('CORS Configuration', () => { + it('should accept config without CORS', () => { + const config = RouterConfigSchema.parse({}); + + expect(config.cors).toBeUndefined(); + }); + + it('should accept CORS configuration', () => { + const config = RouterConfigSchema.parse({ + cors: { + enabled: true, + origins: ['https://example.com'], + methods: ['GET', 'POST'], + }, + }); + + expect(config.cors?.enabled).toBe(true); + }); + }); + + describe('Static Mounts', () => { + it('should accept config without static mounts', () => { + const config = RouterConfigSchema.parse({}); + + expect(config.staticMounts).toBeUndefined(); + }); + + it('should accept static mount configuration', () => { + const config = RouterConfigSchema.parse({ + staticMounts: [ + { + path: '/assets', + directory: '/var/www/assets', + }, + ], + }); + + expect(config.staticMounts).toHaveLength(1); + expect(config.staticMounts![0].path).toBe('/assets'); + }); + + it('should accept multiple static mounts', () => { + const config = RouterConfigSchema.parse({ + staticMounts: [ + { path: '/assets', directory: '/var/www/assets' }, + { path: '/uploads', directory: '/var/www/uploads' }, + { path: '/public', directory: '/var/www/public' }, + ], + }); + + expect(config.staticMounts).toHaveLength(3); + }); + }); + + describe('Complete Configuration Examples', () => { + it('should accept minimal production config', () => { + const config: RouterConfig = { + basePath: '/api', + }; + + const result = RouterConfigSchema.parse(config); + expect(result.basePath).toBe('/api'); + }); + + it('should accept full production config', () => { + const config: RouterConfig = { + basePath: '/api/v1', + mounts: { + data: '/data', + metadata: '/metadata', + auth: '/auth', + automation: '/flows', + storage: '/files', + graphql: '/gql', + }, + cors: { + enabled: true, + origins: ['https://app.example.com', 'https://admin.example.com'], + methods: ['GET', 'POST', 'PUT', 'DELETE', 'PATCH'], + credentials: true, + maxAge: 86400, + }, + staticMounts: [ + { + path: '/assets', + directory: '/var/www/assets', + maxAge: 31536000, + }, + ], + }; + + const result = RouterConfigSchema.parse(config); + expect(result.basePath).toBe('/api/v1'); + expect(result.mounts.graphql).toBe('/gql'); + expect(result.cors?.enabled).toBe(true); + expect(result.staticMounts).toHaveLength(1); + }); + + it('should accept development config', () => { + const config: RouterConfig = { + basePath: '/api', + cors: { + enabled: true, + origins: '*', + methods: ['GET', 'POST', 'PUT', 'DELETE', 'PATCH', 'OPTIONS'], + }, + }; + + const result = RouterConfigSchema.parse(config); + expect(result.cors?.origins).toBe('*'); + }); + }); +}); + +describe('Integration Tests', () => { + it('should support complete routing setup', () => { + // Router config + const routerConfig = RouterConfigSchema.parse({ + basePath: '/api/v1', + mounts: { + data: '/data', + metadata: '/meta', + }, + }); + + // Route definitions + const routes: RouteDefinition[] = [ + { + method: 'GET', + path: '/api/v1/health', + category: 'system', + handler: 'health_check', + public: true, + }, + { + method: 'GET', + path: `${routerConfig.basePath}${routerConfig.mounts.data}/:object`, + category: 'api', + handler: 'list_records', + }, + { + method: 'POST', + path: `${routerConfig.basePath}${routerConfig.mounts.data}/:object`, + category: 'api', + handler: 'create_record', + permissions: ['data.create'], + }, + ]; + + routes.forEach(route => { + expect(() => RouteDefinitionSchema.parse(route)).not.toThrow(); + }); + + expect(routes).toHaveLength(3); + }); + + it('should support route categorization', () => { + const systemRoutes: RouteDefinition[] = [ + { + method: 'GET', + path: '/health', + category: 'system', + handler: 'health', + public: true, + }, + { + method: 'GET', + path: '/metrics', + category: 'system', + handler: 'metrics', + public: false, + permissions: ['admin'], + }, + ]; + + const apiRoutes: RouteDefinition[] = [ + { + method: 'GET', + path: '/api/users', + category: 'api', + handler: 'list_users', + }, + { + method: 'POST', + path: '/api/users', + category: 'api', + handler: 'create_user', + permissions: ['users.create'], + }, + ]; + + [...systemRoutes, ...apiRoutes].forEach(route => { + expect(() => RouteDefinitionSchema.parse(route)).not.toThrow(); + }); + }); +}); From 5966bc8a50dbb4d0a49acdb82e9b05c5b0762726 Mon Sep 17 00:00:00 2001 From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com> Date: Sat, 31 Jan 2026 04:18:09 +0000 Subject: [PATCH 3/4] Add comprehensive tests for view-storage and hook schemas Co-authored-by: xuyushun441-sys <255036401+xuyushun441-sys@users.noreply.github.com> --- packages/spec/src/api/view-storage.test.ts | 673 ++++++++++++++++++++ packages/spec/src/data/hook.test.ts | 690 +++++++++++++++++++++ 2 files changed, 1363 insertions(+) create mode 100644 packages/spec/src/api/view-storage.test.ts create mode 100644 packages/spec/src/data/hook.test.ts diff --git a/packages/spec/src/api/view-storage.test.ts b/packages/spec/src/api/view-storage.test.ts new file mode 100644 index 000000000..4d6970227 --- /dev/null +++ b/packages/spec/src/api/view-storage.test.ts @@ -0,0 +1,673 @@ +import { describe, it, expect } from 'vitest'; +import { + ViewType, + ViewVisibility, + ViewColumnSchema, + ViewLayoutSchema, + SavedViewSchema, + CreateViewRequestSchema, + UpdateViewRequestSchema, + ListViewsRequestSchema, + ViewResponseSchema, + ListViewsResponseSchema, + ViewStorageApiContracts, + type SavedView, + type CreateViewRequest, +} from './view-storage.zod'; + +describe('ViewType', () => { + it('should accept all view types', () => { + const types = ['list', 'kanban', 'calendar', 'gantt', 'timeline', 'chart', 'pivot', 'custom']; + + types.forEach(type => { + expect(() => ViewType.parse(type)).not.toThrow(); + }); + }); + + it('should reject invalid view type', () => { + expect(() => ViewType.parse('invalid')).toThrow(); + }); +}); + +describe('ViewVisibility', () => { + it('should accept all visibility levels', () => { + const levels = ['private', 'shared', 'public', 'organization']; + + levels.forEach(level => { + expect(() => ViewVisibility.parse(level)).not.toThrow(); + }); + }); + + it('should reject invalid visibility', () => { + expect(() => ViewVisibility.parse('invalid')).toThrow(); + }); +}); + +describe('ViewColumnSchema', () => { + it('should accept basic column config', () => { + const column = ViewColumnSchema.parse({ + field: 'email', + }); + + expect(column.field).toBe('email'); + expect(column.sortable).toBe(true); // default + expect(column.filterable).toBe(true); // default + expect(column.visible).toBe(true); // default + }); + + it('should accept column with custom label', () => { + const column = ViewColumnSchema.parse({ + field: 'first_name', + label: 'First Name', + }); + + expect(column.label).toBe('First Name'); + }); + + it('should accept column with width', () => { + const column = ViewColumnSchema.parse({ + field: 'email', + width: 200, + }); + + expect(column.width).toBe(200); + }); + + it('should accept column with options', () => { + const column = ViewColumnSchema.parse({ + field: 'status', + label: 'Status', + width: 150, + sortable: true, + filterable: true, + visible: true, + }); + + expect(column.sortable).toBe(true); + expect(column.filterable).toBe(true); + expect(column.visible).toBe(true); + }); + + it('should accept pinned column', () => { + const column = ViewColumnSchema.parse({ + field: 'name', + pinned: 'left', + }); + + expect(column.pinned).toBe('left'); + }); + + it('should accept column with formatter', () => { + const column = ViewColumnSchema.parse({ + field: 'created_at', + formatter: 'date', + }); + + expect(column.formatter).toBe('date'); + }); + + it('should accept column with aggregation', () => { + const column = ViewColumnSchema.parse({ + field: 'revenue', + aggregation: 'sum', + }); + + expect(column.aggregation).toBe('sum'); + }); +}); + +describe('ViewLayoutSchema', () => { + describe('List View Layout', () => { + it('should accept columns config', () => { + const layout = ViewLayoutSchema.parse({ + columns: [ + { field: 'name', width: 200 }, + { field: 'email', width: 250 }, + { field: 'status', width: 100 }, + ], + }); + + expect(layout.columns).toHaveLength(3); + }); + + it('should accept row height', () => { + const layout = ViewLayoutSchema.parse({ + rowHeight: 40, + }); + + expect(layout.rowHeight).toBe(40); + }); + }); + + describe('Kanban View Layout', () => { + it('should accept kanban config', () => { + const layout = ViewLayoutSchema.parse({ + groupByField: 'status', + cardFields: ['name', 'assignee', 'due_date'], + }); + + expect(layout.groupByField).toBe('status'); + expect(layout.cardFields).toHaveLength(3); + }); + }); + + describe('Calendar View Layout', () => { + it('should accept calendar config', () => { + const layout = ViewLayoutSchema.parse({ + dateField: 'event_date', + titleField: 'subject', + }); + + expect(layout.dateField).toBe('event_date'); + expect(layout.titleField).toBe('subject'); + }); + + it('should accept date range config', () => { + const layout = ViewLayoutSchema.parse({ + startDateField: 'start_date', + endDateField: 'end_date', + titleField: 'event_name', + }); + + expect(layout.startDateField).toBe('start_date'); + expect(layout.endDateField).toBe('end_date'); + }); + }); + + describe('Chart View Layout', () => { + it('should accept chart config', () => { + const layout = ViewLayoutSchema.parse({ + chartType: 'bar', + xAxis: 'month', + yAxis: 'revenue', + }); + + expect(layout.chartType).toBe('bar'); + expect(layout.xAxis).toBe('month'); + expect(layout.yAxis).toBe('revenue'); + }); + + it('should accept multi-series chart', () => { + const layout = ViewLayoutSchema.parse({ + chartType: 'line', + xAxis: 'date', + series: ['revenue', 'expenses', 'profit'], + }); + + expect(layout.series).toHaveLength(3); + }); + }); +}); + +describe('SavedViewSchema', () => { + it('should accept minimal saved view', () => { + const view = SavedViewSchema.parse({ + id: 'view_123', + name: 'active_contacts', + label: 'Active Contacts', + object: 'contact', + type: 'list', + visibility: 'public', + query: { + object: 'contact', + where: { status: 'active' }, + }, + createdBy: 'user_456', + createdAt: '2026-01-29T12:00:00Z', + }); + + expect(view.id).toBe('view_123'); + expect(view.name).toBe('active_contacts'); + expect(view.type).toBe('list'); + }); + + it('should apply default isDefault', () => { + const view = SavedViewSchema.parse({ + id: 'view_123', + name: 'my_view', + label: 'My View', + object: 'account', + type: 'list', + visibility: 'private', + query: { object: 'account' }, + createdBy: 'user_123', + createdAt: '2026-01-29T12:00:00Z', + }); + + expect(view.isDefault).toBe(false); + expect(view.isSystem).toBe(false); + }); + + it('should enforce snake_case for view name', () => { + expect(() => SavedViewSchema.parse({ + id: 'view_123', + name: 'MyView', + label: 'My View', + object: 'account', + type: 'list', + visibility: 'private', + query: { object: 'account' }, + createdBy: 'user_123', + createdAt: '2026-01-29T12:00:00Z', + })).toThrow(); + }); + + it('should accept complete list view', () => { + const view: SavedView = { + id: 'view_123', + name: 'active_contacts', + label: 'Active Contacts', + description: 'All active customer contacts', + object: 'contact', + type: 'list', + visibility: 'public', + query: { + object: 'contact', + where: { status: 'active' }, + orderBy: [{ field: 'last_name', order: 'asc' }], + limit: 50, + }, + layout: { + columns: [ + { field: 'first_name', label: 'First Name', width: 150 }, + { field: 'last_name', label: 'Last Name', width: 150 }, + { field: 'email', label: 'Email', width: 200 }, + { field: 'phone', label: 'Phone', width: 150 }, + ], + rowHeight: 40, + }, + isDefault: false, + isSystem: false, + createdBy: 'user_456', + createdAt: '2026-01-29T12:00:00Z', + updatedBy: 'user_456', + updatedAt: '2026-01-30T10:00:00Z', + }; + + expect(() => SavedViewSchema.parse(view)).not.toThrow(); + }); + + it('should accept kanban view', () => { + const view = SavedViewSchema.parse({ + id: 'view_kanban_1', + name: 'task_kanban', + label: 'Task Board', + object: 'task', + type: 'kanban', + visibility: 'public', + query: { + object: 'task', + where: { project_id: 'proj_123' }, + }, + layout: { + groupByField: 'status', + cardFields: ['name', 'assignee', 'due_date'], + }, + createdBy: 'user_123', + createdAt: '2026-01-29T12:00:00Z', + }); + + expect(view.type).toBe('kanban'); + expect(view.layout?.groupByField).toBe('status'); + }); + + it('should accept shared view', () => { + const view = SavedViewSchema.parse({ + id: 'view_shared_1', + name: 'team_view', + label: 'Team View', + object: 'opportunity', + type: 'list', + visibility: 'shared', + query: { object: 'opportunity' }, + sharedWith: ['team_sales', 'user_manager'], + createdBy: 'user_123', + createdAt: '2026-01-29T12:00:00Z', + }); + + expect(view.visibility).toBe('shared'); + expect(view.sharedWith).toHaveLength(2); + }); + + it('should accept view with settings', () => { + const view = SavedViewSchema.parse({ + id: 'view_custom_1', + name: 'custom_view', + label: 'Custom View', + object: 'account', + type: 'list', + visibility: 'private', + query: { object: 'account' }, + settings: { + autoRefresh: true, + refreshInterval: 30, + highlightRules: [ + { field: 'revenue', operator: 'gt', value: 1000000, color: 'green' }, + ], + }, + createdBy: 'user_123', + createdAt: '2026-01-29T12:00:00Z', + }); + + expect(view.settings).toBeDefined(); + expect(view.settings?.autoRefresh).toBe(true); + }); +}); + +describe('CreateViewRequestSchema', () => { + it('should accept minimal create request', () => { + const request = CreateViewRequestSchema.parse({ + name: 'my_view', + label: 'My View', + object: 'account', + type: 'list', + visibility: 'private', + query: { object: 'account' }, + }); + + expect(request.name).toBe('my_view'); + expect(request.type).toBe('list'); + }); + + it('should accept complete create request', () => { + const request: CreateViewRequest = { + name: 'active_accounts', + label: 'Active Accounts', + description: 'All active accounts', + object: 'account', + type: 'list', + visibility: 'public', + query: { + object: 'account', + where: { status: 'active' }, + orderBy: [{ field: 'name', order: 'asc' }], + }, + layout: { + columns: [ + { field: 'name', width: 200 }, + { field: 'industry', width: 150 }, + ], + }, + sharedWith: ['team_sales'], + isDefault: true, + settings: { autoRefresh: true }, + }; + + expect(() => CreateViewRequestSchema.parse(request)).not.toThrow(); + }); + + it('should apply default isDefault', () => { + const request = CreateViewRequestSchema.parse({ + name: 'test_view', + label: 'Test View', + object: 'contact', + type: 'list', + visibility: 'private', + query: { object: 'contact' }, + }); + + expect(request.isDefault).toBe(false); + }); +}); + +describe('UpdateViewRequestSchema', () => { + it('should accept partial update', () => { + const request = UpdateViewRequestSchema.parse({ + id: 'view_123', + label: 'Updated Label', + }); + + expect(request.id).toBe('view_123'); + expect(request.label).toBe('Updated Label'); + }); + + it('should accept complete update', () => { + const request = UpdateViewRequestSchema.parse({ + id: 'view_123', + name: 'updated_view', + label: 'Updated View', + description: 'Updated description', + query: { + object: 'account', + where: { status: 'active' }, + }, + layout: { + columns: [{ field: 'name', width: 250 }], + }, + }); + + expect(request.id).toBe('view_123'); + }); +}); + +describe('ListViewsRequestSchema', () => { + it('should accept empty request', () => { + const request = ListViewsRequestSchema.parse({}); + + expect(request.limit).toBe(50); // default + expect(request.offset).toBe(0); // default + }); + + it('should accept filter by object', () => { + const request = ListViewsRequestSchema.parse({ + object: 'account', + }); + + expect(request.object).toBe('account'); + }); + + it('should accept filter by type', () => { + const request = ListViewsRequestSchema.parse({ + type: 'kanban', + }); + + expect(request.type).toBe('kanban'); + }); + + it('should accept filter by visibility', () => { + const request = ListViewsRequestSchema.parse({ + visibility: 'public', + }); + + expect(request.visibility).toBe('public'); + }); + + it('should accept filter by creator', () => { + const request = ListViewsRequestSchema.parse({ + createdBy: 'user_123', + }); + + expect(request.createdBy).toBe('user_123'); + }); + + it('should accept pagination params', () => { + const request = ListViewsRequestSchema.parse({ + limit: 25, + offset: 50, + }); + + expect(request.limit).toBe(25); + expect(request.offset).toBe(50); + }); + + it('should accept filter for default views', () => { + const request = ListViewsRequestSchema.parse({ + isDefault: true, + }); + + expect(request.isDefault).toBe(true); + }); +}); + +describe('ViewResponseSchema', () => { + it('should accept successful response', () => { + const response = ViewResponseSchema.parse({ + success: true, + data: { + id: 'view_123', + name: 'my_view', + label: 'My View', + object: 'account', + type: 'list', + visibility: 'private', + query: { object: 'account' }, + createdBy: 'user_123', + createdAt: '2026-01-29T12:00:00Z', + }, + }); + + expect(response.success).toBe(true); + expect(response.data?.id).toBe('view_123'); + }); + + it('should accept error response', () => { + const response = ViewResponseSchema.parse({ + success: false, + error: { + code: 'not_found', + message: 'View not found', + }, + }); + + expect(response.success).toBe(false); + expect(response.error?.code).toBe('not_found'); + }); +}); + +describe('ListViewsResponseSchema', () => { + it('should accept list response', () => { + const response = ListViewsResponseSchema.parse({ + success: true, + data: [ + { + id: 'view_1', + name: 'view_1', + label: 'View 1', + object: 'account', + type: 'list', + visibility: 'public', + query: { object: 'account' }, + createdBy: 'user_123', + createdAt: '2026-01-29T12:00:00Z', + }, + { + id: 'view_2', + name: 'view_2', + label: 'View 2', + object: 'contact', + type: 'kanban', + visibility: 'private', + query: { object: 'contact' }, + createdBy: 'user_456', + createdAt: '2026-01-30T12:00:00Z', + }, + ], + pagination: { + total: 100, + limit: 50, + offset: 0, + hasMore: true, + }, + }); + + expect(response.success).toBe(true); + expect(response.data).toHaveLength(2); + expect(response.pagination.total).toBe(100); + }); + + it('should accept empty list', () => { + const response = ListViewsResponseSchema.parse({ + success: true, + data: [], + pagination: { + total: 0, + limit: 50, + offset: 0, + hasMore: false, + }, + }); + + expect(response.data).toHaveLength(0); + expect(response.pagination.hasMore).toBe(false); + }); +}); + +describe('ViewStorageApiContracts', () => { + it('should have all CRUD contracts', () => { + expect(ViewStorageApiContracts.createView).toBeDefined(); + expect(ViewStorageApiContracts.getView).toBeDefined(); + expect(ViewStorageApiContracts.listViews).toBeDefined(); + expect(ViewStorageApiContracts.updateView).toBeDefined(); + expect(ViewStorageApiContracts.deleteView).toBeDefined(); + }); + + it('should have setDefaultView contract', () => { + expect(ViewStorageApiContracts.setDefaultView).toBeDefined(); + }); + + it('should validate contract inputs and outputs', () => { + // Create View + const createInput = ViewStorageApiContracts.createView.input.parse({ + name: 'test_view', + label: 'Test View', + object: 'account', + type: 'list', + visibility: 'private', + query: { object: 'account' }, + }); + expect(createInput.name).toBe('test_view'); + + // List Views + const listInput = ViewStorageApiContracts.listViews.input.parse({ + object: 'account', + }); + expect(listInput.object).toBe('account'); + + // Delete View + const deleteInput = ViewStorageApiContracts.deleteView.input.parse({ + id: 'view_123', + }); + expect(deleteInput.id).toBe('view_123'); + }); +}); + +describe('Integration Tests', () => { + it('should support complete view workflow', () => { + // Create view + const createRequest = CreateViewRequestSchema.parse({ + name: 'sales_pipeline', + label: 'Sales Pipeline', + object: 'opportunity', + type: 'kanban', + visibility: 'public', + query: { + object: 'opportunity', + where: { stage: { $in: ['prospecting', 'qualification', 'proposal', 'negotiation', 'closed_won'] } }, + }, + layout: { + groupByField: 'stage', + cardFields: ['name', 'amount', 'close_date', 'owner'], + }, + }); + + // List views + const listRequest = ListViewsRequestSchema.parse({ + object: 'opportunity', + type: 'kanban', + }); + + // Update view + const updateRequest = UpdateViewRequestSchema.parse({ + id: 'view_123', + label: 'Updated Sales Pipeline', + layout: { + groupByField: 'stage', + cardFields: ['name', 'amount', 'close_date', 'owner', 'probability'], + }, + }); + + expect(createRequest.name).toBe('sales_pipeline'); + expect(listRequest.type).toBe('kanban'); + expect(updateRequest.id).toBe('view_123'); + }); +}); diff --git a/packages/spec/src/data/hook.test.ts b/packages/spec/src/data/hook.test.ts new file mode 100644 index 000000000..151ff6b06 --- /dev/null +++ b/packages/spec/src/data/hook.test.ts @@ -0,0 +1,690 @@ +import { describe, it, expect } from 'vitest'; +import { + HookEvent, + HookSchema, + HookContextSchema, + type Hook, + type HookContext, +} from './hook.zod'; + +describe('HookEvent', () => { + describe('Read Operations', () => { + it('should accept read operation events', () => { + const readEvents = [ + 'beforeFind', 'afterFind', + 'beforeFindOne', 'afterFindOne', + 'beforeCount', 'afterCount', + 'beforeAggregate', 'afterAggregate', + ]; + + readEvents.forEach(event => { + expect(() => HookEvent.parse(event)).not.toThrow(); + }); + }); + }); + + describe('Write Operations', () => { + it('should accept write operation events', () => { + const writeEvents = [ + 'beforeInsert', 'afterInsert', + 'beforeUpdate', 'afterUpdate', + 'beforeDelete', 'afterDelete', + ]; + + writeEvents.forEach(event => { + expect(() => HookEvent.parse(event)).not.toThrow(); + }); + }); + }); + + describe('Bulk Operations', () => { + it('should accept bulk operation events', () => { + const bulkEvents = [ + 'beforeUpdateMany', 'afterUpdateMany', + 'beforeDeleteMany', 'afterDeleteMany', + ]; + + bulkEvents.forEach(event => { + expect(() => HookEvent.parse(event)).not.toThrow(); + }); + }); + }); + + it('should reject invalid event', () => { + expect(() => HookEvent.parse('invalidEvent')).toThrow(); + }); +}); + +describe('HookSchema', () => { + describe('Basic Hook Properties', () => { + it('should accept minimal valid hook', () => { + const hook = HookSchema.parse({ + name: 'validate_email', + object: 'contact', + events: ['beforeInsert'], + }); + + expect(hook.name).toBe('validate_email'); + expect(hook.object).toBe('contact'); + expect(hook.events).toContain('beforeInsert'); + }); + + it('should enforce snake_case for hook name', () => { + expect(() => HookSchema.parse({ + name: 'ValidateEmail', + object: 'contact', + events: ['beforeInsert'], + })).toThrow(); + + expect(() => HookSchema.parse({ + name: 'validate-email', + object: 'contact', + events: ['beforeInsert'], + })).toThrow(); + }); + + it('should accept valid snake_case names', () => { + const validNames = ['validate_email', 'set_default_values', 'before_save_hook', '_system_hook']; + + validNames.forEach(name => { + expect(() => HookSchema.parse({ + name, + object: 'contact', + events: ['beforeInsert'], + })).not.toThrow(); + }); + }); + + it('should accept hook with label', () => { + const hook = HookSchema.parse({ + name: 'validate_email', + label: 'Email Validation Hook', + object: 'contact', + events: ['beforeInsert', 'beforeUpdate'], + }); + + expect(hook.label).toBe('Email Validation Hook'); + }); + }); + + describe('Object Targeting', () => { + it('should accept single object', () => { + const hook = HookSchema.parse({ + name: 'account_hook', + object: 'account', + events: ['beforeInsert'], + }); + + expect(hook.object).toBe('account'); + }); + + it('should accept multiple objects', () => { + const hook = HookSchema.parse({ + name: 'multi_object_hook', + object: ['account', 'contact', 'opportunity'], + events: ['beforeInsert'], + }); + + expect(hook.object).toEqual(['account', 'contact', 'opportunity']); + }); + + it('should accept wildcard for all objects', () => { + const hook = HookSchema.parse({ + name: 'global_audit', + object: '*', + events: ['afterInsert', 'afterUpdate', 'afterDelete'], + }); + + expect(hook.object).toBe('*'); + }); + }); + + describe('Event Subscription', () => { + it('should accept single event', () => { + const hook = HookSchema.parse({ + name: 'before_save', + object: 'account', + events: ['beforeInsert'], + }); + + expect(hook.events).toHaveLength(1); + expect(hook.events).toContain('beforeInsert'); + }); + + it('should accept multiple events', () => { + const hook = HookSchema.parse({ + name: 'audit_changes', + object: 'account', + events: ['afterInsert', 'afterUpdate', 'afterDelete'], + }); + + expect(hook.events).toHaveLength(3); + }); + + it('should accept before and after events', () => { + const hook = HookSchema.parse({ + name: 'sync_to_external', + object: 'contact', + events: ['beforeInsert', 'afterInsert', 'beforeUpdate', 'afterUpdate'], + }); + + expect(hook.events).toHaveLength(4); + }); + }); + + describe('Handler Configuration', () => { + it('should accept string handler', () => { + const hook = HookSchema.parse({ + name: 'validate_data', + object: 'account', + events: ['beforeInsert'], + handler: 'validators.validateAccount', + }); + + expect(hook.handler).toBe('validators.validateAccount'); + }); + + it('should accept optional handler', () => { + const hook = HookSchema.parse({ + name: 'log_changes', + object: 'account', + events: ['afterUpdate'], + }); + + expect(hook.handler).toBeUndefined(); + }); + }); + + describe('Priority and Execution Order', () => { + it('should apply default priority', () => { + const hook = HookSchema.parse({ + name: 'app_hook', + object: 'account', + events: ['beforeInsert'], + }); + + expect(hook.priority).toBe(100); + }); + + it('should accept system hook priority', () => { + const hook = HookSchema.parse({ + name: 'system_validation', + object: '*', + events: ['beforeInsert'], + priority: 50, + }); + + expect(hook.priority).toBe(50); + }); + + it('should accept user hook priority', () => { + const hook = HookSchema.parse({ + name: 'custom_logic', + object: 'account', + events: ['beforeInsert'], + priority: 1000, + }); + + expect(hook.priority).toBe(1000); + }); + }); + + describe('Async Execution', () => { + it('should default async to false', () => { + const hook = HookSchema.parse({ + name: 'sync_hook', + object: 'account', + events: ['afterInsert'], + }); + + expect(hook.async).toBe(false); + }); + + it('should accept async execution', () => { + const hook = HookSchema.parse({ + name: 'send_notification', + object: 'account', + events: ['afterInsert'], + async: true, + }); + + expect(hook.async).toBe(true); + }); + + it('should accept blocking execution', () => { + const hook = HookSchema.parse({ + name: 'validate_critical', + object: 'account', + events: ['beforeInsert'], + async: false, + }); + + expect(hook.async).toBe(false); + }); + }); + + describe('Error Handling', () => { + it('should default onError to abort', () => { + const hook = HookSchema.parse({ + name: 'validation_hook', + object: 'account', + events: ['beforeInsert'], + }); + + expect(hook.onError).toBe('abort'); + }); + + it('should accept abort error policy', () => { + const hook = HookSchema.parse({ + name: 'critical_validation', + object: 'account', + events: ['beforeInsert'], + onError: 'abort', + }); + + expect(hook.onError).toBe('abort'); + }); + + it('should accept log error policy', () => { + const hook = HookSchema.parse({ + name: 'non_critical_hook', + object: 'account', + events: ['afterInsert'], + onError: 'log', + }); + + expect(hook.onError).toBe('log'); + }); + }); + + describe('Complete Hook Examples', () => { + it('should accept validation hook', () => { + const hook: Hook = { + name: 'validate_account_data', + label: 'Account Data Validation', + object: 'account', + events: ['beforeInsert', 'beforeUpdate'], + handler: 'validators.validateAccountData', + priority: 100, + async: false, + onError: 'abort', + }; + + expect(() => HookSchema.parse(hook)).not.toThrow(); + }); + + it('should accept audit trail hook', () => { + const hook: Hook = { + name: 'audit_trail', + label: 'Audit Trail Logging', + object: '*', + events: ['afterInsert', 'afterUpdate', 'afterDelete'], + handler: 'audit.logChange', + priority: 200, + async: true, + onError: 'log', + }; + + expect(() => HookSchema.parse(hook)).not.toThrow(); + }); + + it('should accept default value hook', () => { + const hook: Hook = { + name: 'set_defaults', + label: 'Set Default Values', + object: 'opportunity', + events: ['beforeInsert'], + handler: 'defaults.setOpportunityDefaults', + priority: 50, + async: false, + onError: 'abort', + }; + + expect(() => HookSchema.parse(hook)).not.toThrow(); + }); + + it('should accept external sync hook', () => { + const hook: Hook = { + name: 'sync_to_salesforce', + label: 'Sync to Salesforce', + object: ['account', 'contact'], + events: ['afterInsert', 'afterUpdate'], + handler: 'integrations.syncToSalesforce', + priority: 500, + async: true, + onError: 'log', + }; + + expect(() => HookSchema.parse(hook)).not.toThrow(); + }); + + it('should accept notification hook', () => { + const hook: Hook = { + name: 'send_email_notification', + label: 'Send Email Notification', + object: 'case', + events: ['afterInsert'], + handler: 'notifications.sendEmail', + priority: 800, + async: true, + onError: 'log', + }; + + expect(() => HookSchema.parse(hook)).not.toThrow(); + }); + }); +}); + +describe('HookContextSchema', () => { + describe('Basic Context Properties', () => { + it('should accept minimal context', () => { + const context = HookContextSchema.parse({ + object: 'account', + event: 'beforeInsert', + input: { doc: { name: 'Test Account' } }, + ql: {}, + }); + + expect(context.object).toBe('account'); + expect(context.event).toBe('beforeInsert'); + }); + + it('should accept context with id', () => { + const context = HookContextSchema.parse({ + id: 'trace_123', + object: 'account', + event: 'beforeInsert', + input: {}, + ql: {}, + }); + + expect(context.id).toBe('trace_123'); + }); + }); + + describe('Input Parameters', () => { + it('should accept find input', () => { + const context = HookContextSchema.parse({ + object: 'account', + event: 'beforeFind', + input: { + query: { where: { status: 'active' } }, + options: {}, + }, + ql: {}, + }); + + expect(context.input.query).toBeDefined(); + }); + + it('should accept insert input', () => { + const context = HookContextSchema.parse({ + object: 'account', + event: 'beforeInsert', + input: { + doc: { + name: 'New Account', + industry: 'Technology', + }, + options: {}, + }, + ql: {}, + }); + + expect(context.input.doc.name).toBe('New Account'); + }); + + it('should accept update input', () => { + const context = HookContextSchema.parse({ + object: 'account', + event: 'beforeUpdate', + input: { + id: '123', + doc: { status: 'active' }, + options: {}, + }, + ql: {}, + }); + + expect(context.input.id).toBe('123'); + expect(context.input.doc.status).toBe('active'); + }); + + it('should accept delete input', () => { + const context = HookContextSchema.parse({ + object: 'account', + event: 'beforeDelete', + input: { + id: '123', + options: {}, + }, + ql: {}, + }); + + expect(context.input.id).toBe('123'); + }); + }); + + describe('Operation Result', () => { + it('should accept result for after hooks', () => { + const context = HookContextSchema.parse({ + object: 'account', + event: 'afterInsert', + input: {}, + result: { + id: '123', + name: 'New Account', + createdAt: '2026-01-31T00:00:00Z', + }, + ql: {}, + }); + + expect(context.result.id).toBe('123'); + }); + + it('should accept array result', () => { + const context = HookContextSchema.parse({ + object: 'account', + event: 'afterFind', + input: {}, + result: [ + { id: '1', name: 'Account 1' }, + { id: '2', name: 'Account 2' }, + ], + ql: {}, + }); + + expect(context.result).toHaveLength(2); + }); + }); + + describe('Previous Data Snapshot', () => { + it('should accept previous data for update', () => { + const context = HookContextSchema.parse({ + object: 'account', + event: 'beforeUpdate', + input: {}, + previous: { + id: '123', + name: 'Old Name', + status: 'inactive', + }, + ql: {}, + }); + + expect(context.previous?.name).toBe('Old Name'); + }); + + it('should accept previous data for delete', () => { + const context = HookContextSchema.parse({ + object: 'account', + event: 'beforeDelete', + input: {}, + previous: { + id: '123', + name: 'Account to Delete', + }, + ql: {}, + }); + + expect(context.previous?.name).toBe('Account to Delete'); + }); + }); + + describe('Session Context', () => { + it('should accept session with user info', () => { + const context = HookContextSchema.parse({ + object: 'account', + event: 'beforeInsert', + input: {}, + session: { + userId: 'user_123', + tenantId: 'tenant_456', + roles: ['user', 'admin'], + }, + ql: {}, + }); + + expect(context.session?.userId).toBe('user_123'); + expect(context.session?.tenantId).toBe('tenant_456'); + expect(context.session?.roles).toContain('admin'); + }); + + it('should accept session with access token', () => { + const context = HookContextSchema.parse({ + object: 'account', + event: 'beforeInsert', + input: {}, + session: { + userId: 'user_123', + accessToken: 'token_abc123', + }, + ql: {}, + }); + + expect(context.session?.accessToken).toBe('token_abc123'); + }); + }); + + describe('Transaction Support', () => { + it('should accept transaction handle', () => { + const context = HookContextSchema.parse({ + object: 'account', + event: 'beforeInsert', + input: {}, + transaction: { id: 'tx_123' }, + ql: {}, + }); + + expect(context.transaction).toBeDefined(); + }); + }); + + describe('Complete Context Examples', () => { + it('should accept complete before insert context', () => { + const context: HookContext = { + id: 'trace_abc123', + object: 'account', + event: 'beforeInsert', + input: { + doc: { + name: 'New Account', + industry: 'Technology', + status: 'active', + }, + options: {}, + }, + session: { + userId: 'user_123', + tenantId: 'tenant_456', + roles: ['user'], + }, + transaction: { id: 'tx_789' }, + ql: {}, + }; + + expect(() => HookContextSchema.parse(context)).not.toThrow(); + }); + + it('should accept complete after update context', () => { + const context: HookContext = { + id: 'trace_def456', + object: 'account', + event: 'afterUpdate', + input: { + id: '123', + doc: { status: 'active' }, + options: {}, + }, + result: { + id: '123', + name: 'Account Name', + status: 'active', + updatedAt: '2026-01-31T00:00:00Z', + }, + previous: { + id: '123', + name: 'Account Name', + status: 'inactive', + }, + session: { + userId: 'user_123', + }, + ql: {}, + }; + + expect(() => HookContextSchema.parse(context)).not.toThrow(); + }); + }); +}); + +describe('Integration Tests', () => { + it('should support hook lifecycle', () => { + // Define hook + const hook = HookSchema.parse({ + name: 'validate_and_enrich', + label: 'Validate and Enrich Data', + object: 'account', + events: ['beforeInsert', 'beforeUpdate'], + handler: 'handlers.validateAndEnrich', + priority: 100, + async: false, + onError: 'abort', + }); + + // Before insert context + const beforeContext = HookContextSchema.parse({ + object: 'account', + event: 'beforeInsert', + input: { + doc: { name: 'Test Account' }, + }, + session: { + userId: 'user_123', + }, + ql: {}, + }); + + // After insert context + const afterContext = HookContextSchema.parse({ + object: 'account', + event: 'afterInsert', + input: { + doc: { name: 'Test Account' }, + }, + result: { + id: '123', + name: 'Test Account', + createdAt: '2026-01-31T00:00:00Z', + }, + session: { + userId: 'user_123', + }, + ql: {}, + }); + + expect(hook.events).toContain('beforeInsert'); + expect(beforeContext.event).toBe('beforeInsert'); + expect(afterContext.result.id).toBe('123'); + }); +}); From 288a1b7860ed414178e0018afaf42f395476324e Mon Sep 17 00:00:00 2001 From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com> Date: Sat, 31 Jan 2026 04:20:17 +0000 Subject: [PATCH 4/4] Add comprehensive tests for data-engine schema (57 tests) Co-authored-by: xuyushun441-sys <255036401+xuyushun441-sys@users.noreply.github.com> --- packages/spec/src/data/data-engine.test.ts | 748 +++++++++++++++++++++ 1 file changed, 748 insertions(+) create mode 100644 packages/spec/src/data/data-engine.test.ts diff --git a/packages/spec/src/data/data-engine.test.ts b/packages/spec/src/data/data-engine.test.ts new file mode 100644 index 000000000..2e1088917 --- /dev/null +++ b/packages/spec/src/data/data-engine.test.ts @@ -0,0 +1,748 @@ +import { describe, it, expect } from 'vitest'; +import { + DataEngineFilterSchema, + DataEngineSortSchema, + DataEngineQueryOptionsSchema, + DataEngineInsertOptionsSchema, + DataEngineUpdateOptionsSchema, + DataEngineDeleteOptionsSchema, + DataEngineAggregateOptionsSchema, + DataEngineCountOptionsSchema, + DataEngineFindRequestSchema, + DataEngineFindOneRequestSchema, + DataEngineInsertRequestSchema, + DataEngineUpdateRequestSchema, + DataEngineDeleteRequestSchema, + DataEngineCountRequestSchema, + DataEngineAggregateRequestSchema, + DataEngineExecuteRequestSchema, + DataEngineVectorFindRequestSchema, + DataEngineBatchRequestSchema, + DataEngineRequestSchema, +} from './data-engine.zod'; + +describe('DataEngineFilterSchema', () => { + it('should accept simple key-value filter', () => { + const filter = DataEngineFilterSchema.parse({ + status: 'active', + category: 'premium', + }); + + expect(filter.status).toBe('active'); + expect(filter.category).toBe('premium'); + }); + + it('should accept complex filter expressions', () => { + const filter = DataEngineFilterSchema.parse({ + operator: 'and', + conditions: [ + { field: 'status', operator: 'eq', value: 'active' }, + { field: 'revenue', operator: 'gt', value: 100000 }, + ], + }); + + expect(filter.operator).toBe('and'); + expect(filter.conditions).toHaveLength(2); + }); + + it('should accept nested filters', () => { + const filter = DataEngineFilterSchema.parse({ + 'address.city': 'New York', + 'address.state': 'NY', + }); + + expect(filter['address.city']).toBe('New York'); + }); +}); + +describe('DataEngineSortSchema', () => { + it('should accept string-based sort', () => { + const sort = DataEngineSortSchema.parse({ + name: 'asc', + created_at: 'desc', + }); + + expect(sort.name).toBe('asc'); + expect(sort.created_at).toBe('desc'); + }); + + it('should accept number-based sort', () => { + const sort = DataEngineSortSchema.parse({ + name: 1, + created_at: -1, + }); + + expect(sort.name).toBe(1); + expect(sort.created_at).toBe(-1); + }); + + it('should accept array-based sort', () => { + const sort = DataEngineSortSchema.parse([ + { field: 'name', order: 'asc' }, + { field: 'created_at', order: 'desc' }, + ]); + + expect(sort).toHaveLength(2); + }); +}); + +describe('DataEngineQueryOptionsSchema', () => { + it('should accept minimal options', () => { + const options = DataEngineQueryOptionsSchema.parse({}); + + expect(options).toBeDefined(); + }); + + it('should accept filter option', () => { + const options = DataEngineQueryOptionsSchema.parse({ + filter: { status: 'active' }, + }); + + expect(options.filter).toBeDefined(); + }); + + it('should accept select option', () => { + const options = DataEngineQueryOptionsSchema.parse({ + select: ['id', 'name', 'email'], + }); + + expect(options.select).toHaveLength(3); + }); + + it('should accept sort option', () => { + const options = DataEngineQueryOptionsSchema.parse({ + sort: { name: 'asc' }, + }); + + expect(options.sort).toBeDefined(); + }); + + it('should accept pagination with limit/skip', () => { + const options = DataEngineQueryOptionsSchema.parse({ + limit: 10, + skip: 20, + }); + + expect(options.limit).toBe(10); + expect(options.skip).toBe(20); + }); + + it('should accept pagination with top', () => { + const options = DataEngineQueryOptionsSchema.parse({ + top: 25, + skip: 50, + }); + + expect(options.top).toBe(25); + expect(options.skip).toBe(50); + }); + + it('should accept populate option', () => { + const options = DataEngineQueryOptionsSchema.parse({ + populate: ['owner', 'contacts'], + }); + + expect(options.populate).toHaveLength(2); + }); + + it('should accept complete query options', () => { + const options = DataEngineQueryOptionsSchema.parse({ + filter: { status: 'active' }, + select: ['id', 'name', 'email'], + sort: { name: 'asc' }, + limit: 50, + skip: 0, + populate: ['owner'], + }); + + expect(options.filter).toBeDefined(); + expect(options.select).toHaveLength(3); + expect(options.limit).toBe(50); + expect(options.populate).toHaveLength(1); + }); +}); + +describe('DataEngineInsertOptionsSchema', () => { + it('should accept returning option', () => { + const options = DataEngineInsertOptionsSchema.parse({ + returning: true, + }); + + expect(options.returning).toBe(true); + }); + + it('should accept custom returning', () => { + const options = DataEngineInsertOptionsSchema.parse({ + returning: false, + }); + + expect(options.returning).toBe(false); + }); +}); + +describe('DataEngineUpdateOptionsSchema', () => { + it('should accept empty options', () => { + const options = DataEngineUpdateOptionsSchema.parse({}); + + expect(options).toBeDefined(); + }); + + it('should accept upsert mode', () => { + const options = DataEngineUpdateOptionsSchema.parse({ + upsert: true, + }); + + expect(options.upsert).toBe(true); + }); + + it('should accept multi-update', () => { + const options = DataEngineUpdateOptionsSchema.parse({ + multi: true, + returning: true, + }); + + expect(options.multi).toBe(true); + expect(options.returning).toBe(true); + }); + + it('should accept update with filter', () => { + const options = DataEngineUpdateOptionsSchema.parse({ + filter: { status: 'inactive' }, + multi: true, + }); + + expect(options.filter).toBeDefined(); + expect(options.multi).toBe(true); + }); +}); + +describe('DataEngineDeleteOptionsSchema', () => { + it('should accept empty options', () => { + const options = DataEngineDeleteOptionsSchema.parse({}); + + expect(options).toBeDefined(); + }); + + it('should accept multi-delete', () => { + const options = DataEngineDeleteOptionsSchema.parse({ + multi: true, + }); + + expect(options.multi).toBe(true); + }); + + it('should accept delete with filter', () => { + const options = DataEngineDeleteOptionsSchema.parse({ + filter: { status: 'archived' }, + multi: true, + }); + + expect(options.filter).toBeDefined(); + }); +}); + +describe('DataEngineAggregateOptionsSchema', () => { + it('should accept group by', () => { + const options = DataEngineAggregateOptionsSchema.parse({ + groupBy: ['status', 'category'], + }); + + expect(options.groupBy).toHaveLength(2); + }); + + it('should accept aggregations', () => { + const options = DataEngineAggregateOptionsSchema.parse({ + aggregations: [ + { field: 'revenue', method: 'sum', alias: 'total_revenue' }, + { field: 'id', method: 'count', alias: 'total_count' }, + ], + }); + + expect(options.aggregations).toHaveLength(2); + }); + + it('should accept all aggregation methods', () => { + const methods = ['count', 'sum', 'avg', 'min', 'max', 'count_distinct'] as const; + + methods.forEach(method => { + const options = DataEngineAggregateOptionsSchema.parse({ + aggregations: [ + { field: 'value', method }, + ], + }); + expect(options.aggregations![0].method).toBe(method); + }); + }); + + it('should accept aggregation with filter and groupBy', () => { + const options = DataEngineAggregateOptionsSchema.parse({ + filter: { status: 'active' }, + groupBy: ['category'], + aggregations: [ + { field: 'revenue', method: 'sum' }, + { field: 'revenue', method: 'avg' }, + ], + }); + + expect(options.filter).toBeDefined(); + expect(options.groupBy).toHaveLength(1); + expect(options.aggregations).toHaveLength(2); + }); +}); + +describe('DataEngineCountOptionsSchema', () => { + it('should accept empty options', () => { + const options = DataEngineCountOptionsSchema.parse({}); + + expect(options).toBeDefined(); + }); + + it('should accept count with filter', () => { + const options = DataEngineCountOptionsSchema.parse({ + filter: { status: 'active' }, + }); + + expect(options.filter).toBeDefined(); + }); +}); + +describe('DataEngineFindRequestSchema', () => { + it('should accept minimal find request', () => { + const request = DataEngineFindRequestSchema.parse({ + method: 'find', + object: 'account', + }); + + expect(request.method).toBe('find'); + expect(request.object).toBe('account'); + }); + + it('should accept find with query', () => { + const request = DataEngineFindRequestSchema.parse({ + method: 'find', + object: 'account', + query: { + filter: { status: 'active' }, + limit: 10, + }, + }); + + expect(request.query?.filter).toBeDefined(); + }); +}); + +describe('DataEngineFindOneRequestSchema', () => { + it('should accept find one request', () => { + const request = DataEngineFindOneRequestSchema.parse({ + method: 'findOne', + object: 'account', + query: { + filter: { id: '123' }, + }, + }); + + expect(request.method).toBe('findOne'); + }); +}); + +describe('DataEngineInsertRequestSchema', () => { + it('should accept single record insert', () => { + const request = DataEngineInsertRequestSchema.parse({ + method: 'insert', + object: 'account', + data: { name: 'Test Account' }, + }); + + expect(request.method).toBe('insert'); + expect(request.data.name).toBe('Test Account'); + }); + + it('should accept multiple records insert', () => { + const request = DataEngineInsertRequestSchema.parse({ + method: 'insert', + object: 'account', + data: [ + { name: 'Account 1' }, + { name: 'Account 2' }, + ], + }); + + expect(request.data).toHaveLength(2); + }); + + it('should accept insert with options', () => { + const request = DataEngineInsertRequestSchema.parse({ + method: 'insert', + object: 'account', + data: { name: 'Test' }, + options: { + returning: true, + }, + }); + + expect(request.options?.returning).toBe(true); + }); +}); + +describe('DataEngineUpdateRequestSchema', () => { + it('should accept update with id', () => { + const request = DataEngineUpdateRequestSchema.parse({ + method: 'update', + object: 'account', + id: '123', + data: { status: 'active' }, + }); + + expect(request.id).toBe('123'); + }); + + it('should accept update with filter', () => { + const request = DataEngineUpdateRequestSchema.parse({ + method: 'update', + object: 'account', + data: { status: 'active' }, + options: { + filter: { category: 'premium' }, + multi: true, + }, + }); + + expect(request.options?.multi).toBe(true); + }); +}); + +describe('DataEngineDeleteRequestSchema', () => { + it('should accept delete with id', () => { + const request = DataEngineDeleteRequestSchema.parse({ + method: 'delete', + object: 'account', + id: '123', + }); + + expect(request.id).toBe('123'); + }); + + it('should accept delete with filter', () => { + const request = DataEngineDeleteRequestSchema.parse({ + method: 'delete', + object: 'account', + options: { + filter: { status: 'archived' }, + multi: true, + }, + }); + + expect(request.options?.multi).toBe(true); + }); +}); + +describe('DataEngineCountRequestSchema', () => { + it('should accept count request', () => { + const request = DataEngineCountRequestSchema.parse({ + method: 'count', + object: 'account', + }); + + expect(request.method).toBe('count'); + }); + + it('should accept count with filter', () => { + const request = DataEngineCountRequestSchema.parse({ + method: 'count', + object: 'account', + query: { + filter: { status: 'active' }, + }, + }); + + expect(request.query?.filter).toBeDefined(); + }); +}); + +describe('DataEngineAggregateRequestSchema', () => { + it('should accept aggregate request', () => { + const request = DataEngineAggregateRequestSchema.parse({ + method: 'aggregate', + object: 'account', + query: { + groupBy: ['status'], + aggregations: [ + { field: 'revenue', method: 'sum' }, + ], + }, + }); + + expect(request.method).toBe('aggregate'); + }); +}); + +describe('DataEngineExecuteRequestSchema', () => { + it('should accept execute with SQL command', () => { + const request = DataEngineExecuteRequestSchema.parse({ + method: 'execute', + command: 'SELECT * FROM accounts WHERE status = ?', + }); + + expect(request.method).toBe('execute'); + }); + + it('should accept execute with object command', () => { + const request = DataEngineExecuteRequestSchema.parse({ + method: 'execute', + command: { + operation: 'custom_query', + params: { limit: 10 }, + }, + }); + + expect(request.command.operation).toBe('custom_query'); + }); + + it('should accept execute with options', () => { + const request = DataEngineExecuteRequestSchema.parse({ + method: 'execute', + command: 'CUSTOM QUERY', + options: { + timeout: 5000, + }, + }); + + expect(request.options?.timeout).toBe(5000); + }); +}); + +describe('DataEngineVectorFindRequestSchema', () => { + it('should accept vector find request', () => { + const request = DataEngineVectorFindRequestSchema.parse({ + method: 'vectorFind', + object: 'documents', + vector: [0.1, 0.2, 0.3, 0.4], + }); + + expect(request.method).toBe('vectorFind'); + expect(request.vector).toHaveLength(4); + }); + + it('should accept custom limit', () => { + const request = DataEngineVectorFindRequestSchema.parse({ + method: 'vectorFind', + object: 'documents', + vector: [0.1, 0.2, 0.3], + limit: 10, + }); + + expect(request.limit).toBe(10); + }); + + it('should accept vector find with filter', () => { + const request = DataEngineVectorFindRequestSchema.parse({ + method: 'vectorFind', + object: 'documents', + vector: [0.1, 0.2, 0.3], + filter: { category: 'tech' }, + limit: 10, + threshold: 0.8, + }); + + expect(request.filter).toBeDefined(); + expect(request.limit).toBe(10); + expect(request.threshold).toBe(0.8); + }); + + it('should accept vector find with select', () => { + const request = DataEngineVectorFindRequestSchema.parse({ + method: 'vectorFind', + object: 'documents', + vector: [0.1, 0.2], + select: ['id', 'title', 'content'], + }); + + expect(request.select).toHaveLength(3); + }); +}); + +describe('DataEngineBatchRequestSchema', () => { + it('should accept batch request', () => { + const request = DataEngineBatchRequestSchema.parse({ + method: 'batch', + requests: [ + { + method: 'find', + object: 'account', + }, + { + method: 'insert', + object: 'contact', + data: { name: 'John Doe' }, + }, + ], + }); + + expect(request.method).toBe('batch'); + expect(request.requests).toHaveLength(2); + }); + + it('should accept transaction mode', () => { + const request = DataEngineBatchRequestSchema.parse({ + method: 'batch', + requests: [ + { method: 'find', object: 'account' }, + ], + transaction: true, + }); + + expect(request.transaction).toBe(true); + }); + + it('should accept non-transactional batch', () => { + const request = DataEngineBatchRequestSchema.parse({ + method: 'batch', + requests: [ + { method: 'find', object: 'account' }, + ], + transaction: false, + }); + + expect(request.transaction).toBe(false); + }); + + it('should accept mixed operations batch', () => { + const request = DataEngineBatchRequestSchema.parse({ + method: 'batch', + requests: [ + { method: 'find', object: 'account' }, + { method: 'count', object: 'contact' }, + { method: 'insert', object: 'opportunity', data: { name: 'Deal' } }, + { method: 'update', object: 'task', id: '123', data: { status: 'done' } }, + { method: 'delete', object: 'note', id: '456' }, + ], + }); + + expect(request.requests).toHaveLength(5); + }); +}); + +describe('DataEngineRequestSchema', () => { + it('should accept all request types', () => { + const requests = [ + { method: 'find' as const, object: 'account' }, + { method: 'findOne' as const, object: 'account' }, + { method: 'insert' as const, object: 'account', data: {} }, + { method: 'update' as const, object: 'account', data: {} }, + { method: 'delete' as const, object: 'account' }, + { method: 'count' as const, object: 'account' }, + { method: 'aggregate' as const, object: 'account', query: {} }, + { method: 'execute' as const, command: 'SQL' }, + { method: 'vectorFind' as const, object: 'docs', vector: [0.1] }, + { method: 'batch' as const, requests: [] }, + ]; + + requests.forEach(request => { + expect(() => DataEngineRequestSchema.parse(request)).not.toThrow(); + }); + }); +}); + +describe('Integration Tests', () => { + it('should support complete CRUD workflow', () => { + // Create + const insertRequest = DataEngineInsertRequestSchema.parse({ + method: 'insert', + object: 'account', + data: { name: 'Test Account', status: 'active' }, + options: { returning: true }, + }); + + // Read + const findRequest = DataEngineFindRequestSchema.parse({ + method: 'find', + object: 'account', + query: { + filter: { status: 'active' }, + select: ['id', 'name', 'status'], + sort: { name: 'asc' }, + limit: 10, + }, + }); + + // Update + const updateRequest = DataEngineUpdateRequestSchema.parse({ + method: 'update', + object: 'account', + id: '123', + data: { status: 'inactive' }, + options: { returning: true }, + }); + + // Delete + const deleteRequest = DataEngineDeleteRequestSchema.parse({ + method: 'delete', + object: 'account', + id: '123', + }); + + expect(insertRequest.method).toBe('insert'); + expect(findRequest.method).toBe('find'); + expect(updateRequest.method).toBe('update'); + expect(deleteRequest.method).toBe('delete'); + }); + + it('should support analytics workflow', () => { + // Count + const countRequest = DataEngineCountRequestSchema.parse({ + method: 'count', + object: 'opportunity', + query: { + filter: { stage: 'closed_won' }, + }, + }); + + // Aggregate + const aggregateRequest = DataEngineAggregateRequestSchema.parse({ + method: 'aggregate', + object: 'opportunity', + query: { + filter: { status: 'closed' }, + groupBy: ['stage', 'owner_id'], + aggregations: [ + { field: 'amount', method: 'sum', alias: 'total_amount' }, + { field: 'amount', method: 'avg', alias: 'avg_amount' }, + { field: 'id', method: 'count', alias: 'deal_count' }, + ], + }, + }); + + expect(countRequest.method).toBe('count'); + expect(aggregateRequest.query.aggregations).toHaveLength(3); + }); + + it('should support batch operations', () => { + const batchRequest = DataEngineBatchRequestSchema.parse({ + method: 'batch', + transaction: true, + requests: [ + { + method: 'insert', + object: 'account', + data: { name: 'New Account' }, + }, + { + method: 'update', + object: 'contact', + id: 'contact_123', + data: { account_id: 'account_new' }, + }, + { + method: 'count', + object: 'opportunity', + query: { + filter: { account_id: 'account_new' }, + }, + }, + ], + }); + + expect(batchRequest.requests).toHaveLength(3); + expect(batchRequest.transaction).toBe(true); + }); +});