From 44ae77560acf99f6540fd61f38345fac0e61d578 Mon Sep 17 00:00:00 2001 From: yogyam Date: Mon, 23 Feb 2026 00:18:34 -0800 Subject: [PATCH] test: add core decorator, schema, and validation tests (76 tests) - decorators.test.ts: 34 tests for @Tool, @Prompt, @Resource, @Auth, @UserEnvs, @UI, @Render, @Deprecated, getMethodMetadata, getDecoratedMethods - schema-generator.test.ts: 17 tests for @Optional, @SchemaConstraint, classToJsonSchemaWithConstraints - validation.test.ts: 25 tests for validatePort, validatePath, validateServiceName, validateNonEmpty, validateUrl - jest.config.js: added jest types to ts-jest tsconfig override --- packages/core/jest.config.js | 1 + .../core/src/__tests__/decorators.test.ts | 481 ++++++++++++++++++ .../src/__tests__/schema-generator.test.ts | 273 ++++++++++ .../core/src/__tests__/validation.test.ts | 159 ++++++ 4 files changed, 914 insertions(+) create mode 100644 packages/core/src/__tests__/decorators.test.ts create mode 100644 packages/core/src/__tests__/schema-generator.test.ts create mode 100644 packages/core/src/__tests__/validation.test.ts diff --git a/packages/core/jest.config.js b/packages/core/jest.config.js index 2221446..307e7fc 100644 --- a/packages/core/jest.config.js +++ b/packages/core/jest.config.js @@ -10,6 +10,7 @@ module.exports = { tsconfig: { experimentalDecorators: true, emitDecoratorMetadata: true, + types: ['jest', 'node', 'reflect-metadata'], }, }, ], diff --git a/packages/core/src/__tests__/decorators.test.ts b/packages/core/src/__tests__/decorators.test.ts new file mode 100644 index 0000000..ea6d4e4 --- /dev/null +++ b/packages/core/src/__tests__/decorators.test.ts @@ -0,0 +1,481 @@ +import 'reflect-metadata'; +import { + Tool, + Prompt, + Resource, + Auth, + UserEnvs, + UI, + Render, + Deprecated, + getMethodMetadata, + getDecoratedMethods +} from '../decorators'; + +// ============================================================================ +// @Tool Decorator +// ============================================================================ + +describe('@Tool', () => { + test('should set tool name from method name', () => { + class TestService { + @Tool() + async myTool() { return {}; } + } + + const descriptor = Object.getOwnPropertyDescriptor(TestService.prototype, 'myTool')!; + expect(Reflect.getMetadata('tool:name', descriptor.value)).toBe('myTool'); + }); + + test('should set tool description', () => { + class TestService { + @Tool({ description: 'Analyze text sentiment' }) + async analyzeSentiment() { return {}; } + } + + const descriptor = Object.getOwnPropertyDescriptor(TestService.prototype, 'analyzeSentiment')!; + expect(Reflect.getMetadata('tool:description', descriptor.value)).toBe('Analyze text sentiment'); + }); + + test('should default description to empty string', () => { + class TestService { + @Tool() + async noDesc() { return {}; } + } + + const descriptor = Object.getOwnPropertyDescriptor(TestService.prototype, 'noDesc')!; + expect(Reflect.getMetadata('tool:description', descriptor.value)).toBe(''); + }); + + test('should store inputClass when provided', () => { + class MyInput { + text!: string; + } + + class TestService { + @Tool({ description: 'Test', inputClass: MyInput }) + async withInput(args: MyInput) { return {}; } + } + + const descriptor = Object.getOwnPropertyDescriptor(TestService.prototype, 'withInput')!; + expect(Reflect.getMetadata('tool:inputClass', descriptor.value)).toBe(MyInput); + }); + + test('should not set inputClass when not provided', () => { + class TestService { + @Tool() + async noInput() { return {}; } + } + + const descriptor = Object.getOwnPropertyDescriptor(TestService.prototype, 'noInput')!; + expect(Reflect.getMetadata('tool:inputClass', descriptor.value)).toBeUndefined(); + }); + + test('should store securitySchemes when provided', () => { + class TestService { + @Tool({ + description: 'Secure tool', + securitySchemes: [{ type: 'oauth2', scopes: ['read:user'] }] + }) + async secureTool() { return {}; } + } + + const descriptor = Object.getOwnPropertyDescriptor(TestService.prototype, 'secureTool')!; + const schemes = Reflect.getMetadata('tool:securitySchemes', descriptor.value); + expect(schemes).toEqual([{ type: 'oauth2', scopes: ['read:user'] }]); + }); + + test('should store propertyKey', () => { + class TestService { + @Tool() + async myMethod() { return {}; } + } + + const descriptor = Object.getOwnPropertyDescriptor(TestService.prototype, 'myMethod')!; + expect(Reflect.getMetadata('tool:propertyKey', descriptor.value)).toBe('myMethod'); + }); +}); + +// ============================================================================ +// @Prompt Decorator +// ============================================================================ + +describe('@Prompt', () => { + test('should set prompt name from method name', () => { + class TestService { + @Prompt() + greetingPrompt() { return { messages: [] }; } + } + + const descriptor = Object.getOwnPropertyDescriptor(TestService.prototype, 'greetingPrompt')!; + expect(Reflect.getMetadata('prompt:name', descriptor.value)).toBe('greetingPrompt'); + }); + + test('should set prompt description', () => { + class TestService { + @Prompt({ description: 'Generate greeting' }) + greeting() { return { messages: [] }; } + } + + const descriptor = Object.getOwnPropertyDescriptor(TestService.prototype, 'greeting')!; + expect(Reflect.getMetadata('prompt:description', descriptor.value)).toBe('Generate greeting'); + }); + + test('should store inputClass when provided', () => { + class PromptInput { + name!: string; + } + + class TestService { + @Prompt({ description: 'Test', inputClass: PromptInput }) + myPrompt(args: PromptInput) { return { messages: [] }; } + } + + const descriptor = Object.getOwnPropertyDescriptor(TestService.prototype, 'myPrompt')!; + expect(Reflect.getMetadata('prompt:inputClass', descriptor.value)).toBe(PromptInput); + }); + + test('should store propertyKey', () => { + class TestService { + @Prompt() + myPrompt() { return { messages: [] }; } + } + + const descriptor = Object.getOwnPropertyDescriptor(TestService.prototype, 'myPrompt')!; + expect(Reflect.getMetadata('prompt:propertyKey', descriptor.value)).toBe('myPrompt'); + }); +}); + +// ============================================================================ +// @Resource Decorator +// ============================================================================ + +describe('@Resource', () => { + test('should set resource name from method name', () => { + class TestService { + @Resource() + getStats() { return {}; } + } + + const descriptor = Object.getOwnPropertyDescriptor(TestService.prototype, 'getStats')!; + expect(Reflect.getMetadata('resource:name', descriptor.value)).toBe('getStats'); + }); + + test('should auto-generate ui:// URI from class and method name', () => { + class ProductSearchService { + @Resource() + getCatalog() { return {}; } + } + + const descriptor = Object.getOwnPropertyDescriptor(ProductSearchService.prototype, 'getCatalog')!; + // Class name "ProductSearchService" → "productsearch" (lowercased, "service" removed) + expect(Reflect.getMetadata('resource:uri', descriptor.value)).toBe('ui://productsearch/getCatalog'); + }); + + test('should allow custom URI override', () => { + class TestService { + @Resource({ uri: 'custom://my-resource' }) + myResource() { return {}; } + } + + const descriptor = Object.getOwnPropertyDescriptor(TestService.prototype, 'myResource')!; + expect(Reflect.getMetadata('resource:uri', descriptor.value)).toBe('custom://my-resource'); + }); + + test('should set description', () => { + class TestService { + @Resource({ description: 'Server info' }) + serverInfo() { return {}; } + } + + const descriptor = Object.getOwnPropertyDescriptor(TestService.prototype, 'serverInfo')!; + expect(Reflect.getMetadata('resource:description', descriptor.value)).toBe('Server info'); + }); + + test('should set mimeType', () => { + class TestService { + @Resource({ mimeType: 'text/plain' }) + getText() { return 'hello'; } + } + + const descriptor = Object.getOwnPropertyDescriptor(TestService.prototype, 'getText')!; + expect(Reflect.getMetadata('resource:mimeType', descriptor.value)).toBe('text/plain'); + }); + + test('should default mimeType to application/json', () => { + class TestService { + @Resource() + getData() { return {}; } + } + + const descriptor = Object.getOwnPropertyDescriptor(TestService.prototype, 'getData')!; + expect(Reflect.getMetadata('resource:mimeType', descriptor.value)).toBe('application/json'); + }); + + test('should store inputClass when provided', () => { + class ResourceInput { + id!: string; + } + + class TestService { + @Resource({ inputClass: ResourceInput }) + getById(args: ResourceInput) { return {}; } + } + + const descriptor = Object.getOwnPropertyDescriptor(TestService.prototype, 'getById')!; + expect(Reflect.getMetadata('resource:inputClass', descriptor.value)).toBe(ResourceInput); + }); +}); + +// ============================================================================ +// @Auth Decorator +// ============================================================================ + +describe('@Auth', () => { + test('should set auth metadata on method', () => { + class TestService { + @Auth({ provider: 'clerk' }) + async protectedMethod() { return {}; } + } + + const descriptor = Object.getOwnPropertyDescriptor(TestService.prototype, 'protectedMethod')!; + expect(Reflect.getMetadata('auth:provider', descriptor.value)).toBe('clerk'); + expect(Reflect.getMetadata('auth:required', descriptor.value)).toBe(true); + }); + + test('should set auth metadata on class', () => { + @Auth({ provider: 'cognito' }) + class ProtectedService { + async method() { return {}; } + } + + expect(Reflect.getMetadata('auth:provider', ProtectedService)).toBe('cognito'); + expect(Reflect.getMetadata('auth:required', ProtectedService)).toBe(true); + }); +}); + +// ============================================================================ +// @UserEnvs Decorator +// ============================================================================ + +describe('@UserEnvs', () => { + test('should store property key on constructor', () => { + class TestService { + @UserEnvs() + envConfig: any; + } + + expect(Reflect.getMetadata('userenvs:propertyKey', TestService)).toBe('envConfig'); + }); +}); + +// ============================================================================ +// @UI Decorator +// ============================================================================ + +describe('@UI', () => { + test('should set UI component on method', () => { + class TestService { + @UI('DataGrid') + async getData() { return {}; } + } + + const descriptor = Object.getOwnPropertyDescriptor(TestService.prototype, 'getData')!; + expect(Reflect.getMetadata('ui:component', descriptor.value)).toBe('DataGrid'); + }); + + test('should set UI component on class', () => { + @UI('Dashboard') + class DashboardService { + async getData() { return {}; } + } + + expect(Reflect.getMetadata('ui:component', DashboardService)).toBe('Dashboard'); + }); +}); + +// ============================================================================ +// @Render Decorator +// ============================================================================ + +describe('@Render', () => { + test('should set render format', () => { + class TestService { + @Render('markdown') + async getReport() { return {}; } + } + + const descriptor = Object.getOwnPropertyDescriptor(TestService.prototype, 'getReport')!; + expect(Reflect.getMetadata('render:format', descriptor.value)).toBe('markdown'); + }); + + test('should support all format types', () => { + const formats = ['markdown', 'html', 'json', 'chart', 'table'] as const; + + for (const format of formats) { + class TestService { + @Render(format) + async method() { return {}; } + } + + const descriptor = Object.getOwnPropertyDescriptor(TestService.prototype, 'method')!; + expect(Reflect.getMetadata('render:format', descriptor.value)).toBe(format); + } + }); +}); + +// ============================================================================ +// @Deprecated Decorator +// ============================================================================ + +describe('@Deprecated', () => { + let warnSpy: jest.SpyInstance; + + beforeEach(() => { + warnSpy = jest.spyOn(console, 'warn').mockImplementation(); + }); + + afterEach(() => { + warnSpy.mockRestore(); + }); + + test('should wrap method to log deprecation warning with custom message', () => { + class TestService { + @Deprecated('Use newMethod instead') + async oldMethod() { return {}; } + } + + const service = new TestService(); + service.oldMethod(); + + expect(warnSpy).toHaveBeenCalledWith('DEPRECATED: oldMethod - Use newMethod instead'); + }); + + test('should use default deprecation message when none provided', () => { + class TestService { + @Deprecated() + async oldMethod() { return {}; } + } + + const service = new TestService(); + service.oldMethod(); + + expect(warnSpy).toHaveBeenCalledWith('DEPRECATED: oldMethod - This feature is deprecated'); + }); + + test('should still execute the original method and return its result', async () => { + class TestService { + @Deprecated() + async oldMethod() { return { value: 42 }; } + } + + const service = new TestService(); + const result = await service.oldMethod(); + expect(result).toEqual({ value: 42 }); + }); + + test('should set deprecation metadata on class', () => { + @Deprecated('This service is deprecated') + class OldService { + async method() { return {}; } + } + + expect(Reflect.getMetadata('deprecated:true', OldService)).toBe(true); + expect(Reflect.getMetadata('deprecated:message', OldService)).toBe('This service is deprecated'); + }); +}); + +// ============================================================================ +// getMethodMetadata() +// ============================================================================ + +describe('getMethodMetadata', () => { + test('should return all metadata for a decorated method', () => { + class TestService { + @Tool({ description: 'My tool' }) + @Render('json') + async myTool() { return {}; } + } + + const descriptor = Object.getOwnPropertyDescriptor(TestService.prototype, 'myTool')!; + const meta = getMethodMetadata(descriptor.value as Function); + + expect(meta.toolName).toBe('myTool'); + expect(meta.toolDescription).toBe('My tool'); + expect(meta.renderFormat).toBe('json'); + }); + + test('should return undefined for unset metadata', () => { + class TestService { + @Tool() + async myTool() { return {}; } + } + + const descriptor = Object.getOwnPropertyDescriptor(TestService.prototype, 'myTool')!; + const meta = getMethodMetadata(descriptor.value as Function); + + expect(meta.toolName).toBe('myTool'); + expect(meta.promptName).toBeUndefined(); + expect(meta.resourceUri).toBeUndefined(); + expect(meta.authProvider).toBeUndefined(); + }); +}); + +// ============================================================================ +// getDecoratedMethods() +// ============================================================================ + +describe('getDecoratedMethods', () => { + test('should find all @Tool methods on a class', () => { + class TestService { + @Tool({ description: 'Tool A' }) + async toolA() { return {}; } + + @Tool({ description: 'Tool B' }) + async toolB() { return {}; } + + async notATool() { return {}; } + } + + const tools = getDecoratedMethods(TestService, 'tool:name'); + expect(tools).toHaveLength(2); + expect(tools.map(t => t.propertyKey)).toContain('toolA'); + expect(tools.map(t => t.propertyKey)).toContain('toolB'); + }); + + test('should find @Prompt methods', () => { + class TestService { + @Prompt({ description: 'A prompt' }) + myPrompt() { return { messages: [] }; } + + @Tool() + async myTool() { return {}; } + } + + const prompts = getDecoratedMethods(TestService, 'prompt:name'); + expect(prompts).toHaveLength(1); + expect(prompts[0].propertyKey).toBe('myPrompt'); + }); + + test('should find @Resource methods', () => { + class TestService { + @Resource({ description: 'A resource' }) + myResource() { return {}; } + + @Resource({ description: 'Another resource' }) + anotherResource() { return {}; } + } + + const resources = getDecoratedMethods(TestService, 'resource:uri'); + expect(resources).toHaveLength(2); + }); + + test('should return empty array when no decorated methods found', () => { + class TestService { + async plainMethod() { return {}; } + } + + const tools = getDecoratedMethods(TestService, 'tool:name'); + expect(tools).toHaveLength(0); + }); +}); diff --git a/packages/core/src/__tests__/schema-generator.test.ts b/packages/core/src/__tests__/schema-generator.test.ts new file mode 100644 index 0000000..82712ab --- /dev/null +++ b/packages/core/src/__tests__/schema-generator.test.ts @@ -0,0 +1,273 @@ +import 'reflect-metadata'; + +// Mock type-parser to avoid import.meta.url ESM incompatibility with ts-jest +jest.mock('../type-parser', () => ({ + parseClassTypesSync: () => null, + registerClassSource: () => { }, +})); + +import { Optional, SchemaConstraint, classToJsonSchemaWithConstraints } from '../schema-generator'; + +// ============================================================================ +// @Optional Decorator +// ============================================================================ + +describe('@Optional', () => { + test('should mark property as optional', () => { + class TestInput { + @Optional() + optionalField?: string; + } + + const instance = new TestInput(); + expect(Reflect.getMetadata('optional', instance, 'optionalField')).toBe(true); + }); + + test('should not mark other properties as optional', () => { + class TestInput { + requiredField!: string; + + @Optional() + optionalField?: string; + } + + const instance = new TestInput(); + expect(Reflect.getMetadata('optional', instance, 'requiredField')).toBeUndefined(); + expect(Reflect.getMetadata('optional', instance, 'optionalField')).toBe(true); + }); +}); + +// ============================================================================ +// @SchemaConstraint Decorator +// ============================================================================ + +describe('@SchemaConstraint', () => { + test('should store description constraint', () => { + class TestInput { + @SchemaConstraint({ description: 'User name' }) + name!: string; + } + + const instance = new TestInput(); + const constraints = Reflect.getMetadata('schema:constraints', instance, 'name'); + expect(constraints.description).toBe('User name'); + }); + + test('should store string constraints', () => { + class TestInput { + @SchemaConstraint({ minLength: 1, maxLength: 100, pattern: '^[a-z]+$' }) + text!: string; + } + + const instance = new TestInput(); + const constraints = Reflect.getMetadata('schema:constraints', instance, 'text'); + expect(constraints.minLength).toBe(1); + expect(constraints.maxLength).toBe(100); + expect(constraints.pattern).toBe('^[a-z]+$'); + }); + + test('should store number constraints', () => { + class TestInput { + @SchemaConstraint({ minimum: 0, maximum: 100 }) + score!: number; + } + + const instance = new TestInput(); + const constraints = Reflect.getMetadata('schema:constraints', instance, 'score'); + expect(constraints.minimum).toBe(0); + expect(constraints.maximum).toBe(100); + }); + + test('should store enum constraint', () => { + class TestInput { + @SchemaConstraint({ enum: ['asc', 'desc'] }) + sortOrder!: string; + } + + const instance = new TestInput(); + const constraints = Reflect.getMetadata('schema:constraints', instance, 'sortOrder'); + expect(constraints.enum).toEqual(['asc', 'desc']); + }); + + test('should store default value', () => { + class TestInput { + @SchemaConstraint({ default: 10, description: 'Page size' }) + pageSize!: number; + } + + const instance = new TestInput(); + const constraints = Reflect.getMetadata('schema:constraints', instance, 'pageSize'); + expect(constraints.default).toBe(10); + }); + + test('should store multiple constraints on different properties', () => { + class TestInput { + @SchemaConstraint({ description: 'First name', minLength: 1 }) + firstName!: string; + + @SchemaConstraint({ description: 'Age', minimum: 0, maximum: 150 }) + age!: number; + } + + const instance = new TestInput(); + const nameConstraints = Reflect.getMetadata('schema:constraints', instance, 'firstName'); + const ageConstraints = Reflect.getMetadata('schema:constraints', instance, 'age'); + + expect(nameConstraints.description).toBe('First name'); + expect(nameConstraints.minLength).toBe(1); + expect(ageConstraints.minimum).toBe(0); + expect(ageConstraints.maximum).toBe(150); + }); +}); + +// ============================================================================ +// classToJsonSchemaWithConstraints() +// ============================================================================ + +describe('classToJsonSchemaWithConstraints', () => { + test('should generate schema with required fields', () => { + class TestInput { + @SchemaConstraint({ description: 'Query text' }) + query!: string; + } + + const schema = classToJsonSchemaWithConstraints(TestInput); + + expect(schema.type).toBe('object'); + expect(schema.properties.query).toBeDefined(); + expect(schema.properties.query.description).toBe('Query text'); + expect(schema.required).toContain('query'); + }); + + test('should mark @Optional fields as not required', () => { + class TestInput { + @SchemaConstraint({ description: 'Required field' }) + required!: string; + + @Optional() + @SchemaConstraint({ description: 'Optional field' }) + optional?: string; + } + + const schema = classToJsonSchemaWithConstraints(TestInput); + + expect(schema.required).toContain('required'); + expect(schema.required).not.toContain('optional'); + }); + + test('should include constraints in schema properties', () => { + class TestInput { + @SchemaConstraint({ description: 'Name', minLength: 1, maxLength: 50 }) + name!: string; + + @SchemaConstraint({ description: 'Count', minimum: 0, maximum: 100 }) + count!: number; + } + + const schema = classToJsonSchemaWithConstraints(TestInput); + + expect(schema.properties.name.minLength).toBe(1); + expect(schema.properties.name.maxLength).toBe(50); + expect(schema.properties.count.minimum).toBe(0); + expect(schema.properties.count.maximum).toBe(100); + }); + + test('should include enum in schema', () => { + class TestInput { + @SchemaConstraint({ + description: 'Sort order', + enum: ['price_asc', 'price_desc', 'rating'] + }) + sortBy!: string; + } + + const schema = classToJsonSchemaWithConstraints(TestInput); + + expect(schema.properties.sortBy.enum).toEqual(['price_asc', 'price_desc', 'rating']); + }); + + test('should include default values in schema', () => { + class TestInput { + @Optional() + @SchemaConstraint({ description: 'Page size', default: 10 }) + pageSize?: number; + } + + const schema = classToJsonSchemaWithConstraints(TestInput); + + expect(schema.properties.pageSize.default).toBe(10); + }); + + test('should handle class with no properties', () => { + class EmptyInput { } + + const schema = classToJsonSchemaWithConstraints(EmptyInput); + + expect(schema.type).toBe('object'); + expect(Object.keys(schema.properties)).toHaveLength(0); + }); + + test('should infer string type from string constraints', () => { + class TestInput { + @SchemaConstraint({ minLength: 1 }) + text!: string; + } + + const schema = classToJsonSchemaWithConstraints(TestInput); + + // When design:type is unavailable, minLength implies string type + expect(schema.properties.text.type).toBe('string'); + }); + + test('should infer number type from number constraints', () => { + class TestInput { + @SchemaConstraint({ minimum: 0, maximum: 100 }) + score!: number; + } + + const schema = classToJsonSchemaWithConstraints(TestInput); + + // When design:type is unavailable, minimum/maximum imply number type + expect(schema.properties.score.type).toBe('number'); + }); + + test('should handle a realistic input class', () => { + class SearchInput { + @Optional() + @SchemaConstraint({ description: 'Search query', default: '' }) + query?: string; + + @Optional() + @SchemaConstraint({ + description: 'Category filter', + enum: ['Electronics', 'Books', 'Sports'] + }) + category?: string; + + @Optional() + @SchemaConstraint({ description: 'Min price', minimum: 0 }) + minPrice?: number; + + @Optional() + @SchemaConstraint({ description: 'Page', minimum: 1, default: 1 }) + page?: number; + + @SchemaConstraint({ description: 'Product ID', minLength: 1 }) + productId!: string; + } + + const schema = classToJsonSchemaWithConstraints(SearchInput); + + // Only productId should be required + expect(schema.required).toEqual(['productId']); + + // All 5 properties should exist + expect(Object.keys(schema.properties)).toHaveLength(5); + + // Verify constraints merged + expect(schema.properties.category.enum).toEqual(['Electronics', 'Books', 'Sports']); + expect(schema.properties.minPrice.minimum).toBe(0); + expect(schema.properties.page.default).toBe(1); + expect(schema.properties.productId.minLength).toBe(1); + }); +}); diff --git a/packages/core/src/__tests__/validation.test.ts b/packages/core/src/__tests__/validation.test.ts new file mode 100644 index 0000000..43d88de --- /dev/null +++ b/packages/core/src/__tests__/validation.test.ts @@ -0,0 +1,159 @@ +import { + validatePort, + validatePath, + validateServiceName, + validateNonEmpty, + validateUrl +} from '../validation'; + +// ============================================================================ +// validatePort() +// ============================================================================ + +describe('validatePort', () => { + test('should accept valid ports', () => { + expect(() => validatePort(1)).not.toThrow(); + expect(() => validatePort(80)).not.toThrow(); + expect(() => validatePort(3000)).not.toThrow(); + expect(() => validatePort(8080)).not.toThrow(); + expect(() => validatePort(65535)).not.toThrow(); + }); + + test('should reject port 0', () => { + expect(() => validatePort(0)).toThrow('Invalid port'); + }); + + test('should reject negative ports', () => { + expect(() => validatePort(-1)).toThrow('Invalid port'); + expect(() => validatePort(-100)).toThrow('Invalid port'); + }); + + test('should reject ports above 65535', () => { + expect(() => validatePort(65536)).toThrow('Invalid port'); + expect(() => validatePort(70000)).toThrow('Invalid port'); + }); + + test('should reject non-integer ports', () => { + expect(() => validatePort(3.14)).toThrow('Invalid port'); + expect(() => validatePort(80.5)).toThrow('Invalid port'); + }); + + test('should reject NaN', () => { + expect(() => validatePort(NaN)).toThrow('Invalid port'); + }); +}); + +// ============================================================================ +// validatePath() +// ============================================================================ + +describe('validatePath', () => { + test('should accept valid paths', () => { + expect(() => validatePath('./services')).not.toThrow(); + expect(() => validatePath('mcp/products')).not.toThrow(); + expect(() => validatePath('/absolute/path')).not.toThrow(); + expect(() => validatePath('file.ts')).not.toThrow(); + }); + + test('should reject directory traversal with ..', () => { + expect(() => validatePath('../etc/passwd')).toThrow('Path traversal'); + expect(() => validatePath('foo/../bar')).toThrow('Path traversal'); + expect(() => validatePath('../../secret')).toThrow('Path traversal'); + }); + + test('should reject home directory with ~', () => { + expect(() => validatePath('~/secrets')).toThrow('Path traversal'); + expect(() => validatePath('~/.ssh/id_rsa')).toThrow('Path traversal'); + }); +}); + +// ============================================================================ +// validateServiceName() +// ============================================================================ + +describe('validateServiceName', () => { + test('should accept valid service names', () => { + expect(() => validateServiceName('my-service')).not.toThrow(); + expect(() => validateServiceName('my_service')).not.toThrow(); + expect(() => validateServiceName('service123')).not.toThrow(); + expect(() => validateServiceName('MyService')).not.toThrow(); + expect(() => validateServiceName('a')).not.toThrow(); + }); + + test('should reject names with spaces', () => { + expect(() => validateServiceName('my service')).toThrow('Invalid service name'); + }); + + test('should reject names with special characters', () => { + expect(() => validateServiceName('my.service')).toThrow('Invalid service name'); + expect(() => validateServiceName('my/service')).toThrow('Invalid service name'); + expect(() => validateServiceName('../malicious')).toThrow('Invalid service name'); + expect(() => validateServiceName('service@home')).toThrow('Invalid service name'); + }); + + test('should reject empty string', () => { + expect(() => validateServiceName('')).toThrow('Invalid service name'); + }); +}); + +// ============================================================================ +// validateNonEmpty() +// ============================================================================ + +describe('validateNonEmpty', () => { + test('should accept non-empty strings', () => { + expect(() => validateNonEmpty('hello', 'name')).not.toThrow(); + expect(() => validateNonEmpty('a', 'field')).not.toThrow(); + expect(() => validateNonEmpty(' text ', 'value')).not.toThrow(); + }); + + test('should reject empty string', () => { + expect(() => validateNonEmpty('', 'name')).toThrow('name cannot be empty'); + }); + + test('should reject whitespace-only string', () => { + expect(() => validateNonEmpty(' ', 'field')).toThrow('field cannot be empty'); + expect(() => validateNonEmpty('\t\n', 'value')).toThrow('value cannot be empty'); + }); + + test('should include field name in error message', () => { + expect(() => validateNonEmpty('', 'serverName')).toThrow('serverName cannot be empty'); + }); +}); + +// ============================================================================ +// validateUrl() +// ============================================================================ + +describe('validateUrl', () => { + test('should accept valid HTTP URLs', () => { + expect(() => validateUrl('http://localhost:3000')).not.toThrow(); + expect(() => validateUrl('http://example.com')).not.toThrow(); + }); + + test('should accept valid HTTPS URLs', () => { + expect(() => validateUrl('https://example.com')).not.toThrow(); + expect(() => validateUrl('https://api.example.com/v1')).not.toThrow(); + }); + + test('should reject file:// protocol by default', () => { + expect(() => validateUrl('file:///etc/passwd')).toThrow('Invalid URL protocol'); + }); + + test('should reject javascript: protocol', () => { + expect(() => validateUrl('javascript:alert(1)')).toThrow(); + }); + + test('should reject invalid URL strings', () => { + expect(() => validateUrl('not-a-url')).toThrow('Invalid URL'); + expect(() => validateUrl('')).toThrow('Invalid URL'); + }); + + test('should accept custom allowed protocols', () => { + expect(() => validateUrl('ftp://files.example.com', ['ftp:'])).not.toThrow(); + }); + + test('should reject non-allowed protocol in custom list', () => { + expect(() => validateUrl('http://example.com', ['https:'])).toThrow('Invalid URL protocol'); + }); +});