diff --git a/.claude/settings.local.json b/.claude/settings.local.json new file mode 100644 index 0000000..3c52a00 --- /dev/null +++ b/.claude/settings.local.json @@ -0,0 +1,9 @@ +{ + "permissions": { + "allow": [ + "Bash(npm run test)" + ], + "deny": [], + "ask": [] + } +} diff --git a/.mock-config.json b/.mock-config.json new file mode 100644 index 0000000..d306925 --- /dev/null +++ b/.mock-config.json @@ -0,0 +1,7 @@ +{ + "typesDir": "..\\testfront\\types", + "port": 8080, + "hotReload": true, + "cache": true, + "verbose": false +} \ No newline at end of file diff --git a/README.md b/README.md index 1bb42a9..f2a4e05 100644 --- a/README.md +++ b/README.md @@ -113,12 +113,93 @@ All endpoints are documented with examples and you can test them directly from y --- +## 🎯 Field Constraints with JSDoc Annotations + +Add validation constraints to your interfaces using JSDoc annotations. This ensures generated mock data follows your API rules. + +### Supported Constraints + +| Annotation | Type | Example | Description | +|---|---|---|---| +| `@minLength` | string | `@minLength 3` | Minimum string length | +| `@maxLength` | string | `@maxLength 10` | Maximum string length | +| `@pattern` | string | `@pattern ^[a-z]+$` | Regex pattern validation | +| `@min` | number | `@min 1` | Minimum numeric value | +| `@max` | number | `@max 100` | Maximum numeric value | +| `@enum` | any | `@enum ACTIVE,INACTIVE,PENDING` | Allowed values (comma-separated) | + +### Usage Examples + +```typescript +// @endpoint +export interface Badge { + /** @maxLength 10 */ + label: string; + + /** @min 1 @max 5 */ + level: number; + + /** @enum ACTIVE,INACTIVE,PENDING */ + status: string; +} +``` + +Response: +```json +{ "label": "New", "level": 3, "status": "ACTIVE" } +``` + +### More Examples + +```typescript +// @endpoint +export interface User { + id: number; + + /** @minLength 3 @maxLength 20 */ + username: string; + + /** @pattern ^[a-zA-Z0-9._%+-]+@[a-zA-Z0-9.-]+\.[a-zA-Z]{2,}$ */ + email: string; + + /** @min 18 @max 120 */ + age: number; +} + +// @endpoint +export interface Product { + /** @maxLength 50 */ + title: string; + + /** @minLength 10 @maxLength 500 */ + description: string; + + /** @min 0.01 @max 999999.99 */ + price: number; + + /** @enum DRAFT,PUBLISHED,ARCHIVED */ + status: string; +} +``` + +### How It Works + +1. Constraints are extracted from JSDoc comments when generating mock data +2. Intermock generates base mock data +3. The constraint resolver applies your rules to ensure valid data +4. **No validation** - constraints are applied, not enforced. Mocks always return valid data. + + + ## Available Commands - `npm run dev` - Start development server - `npm run build` - Compile TypeScript - `npm start` - Start production server -- `npm test` - Run tests +- `npm test` - Run all tests +- `npm test -- constraintExtractor.test.ts` - Test constraint JSDoc extraction +- `npm test -- constraintValidator.test.ts` - Test constraint validation +- `npm test -- constrainedGenerator.test.ts` - Test constrained data generation --- @@ -129,7 +210,13 @@ The server maps URL paths to TypeScript interfaces by converting the route to Pa - `/user` → looks for `User` interface → returns single object - `/users` → looks for `User` interface → returns array of 3-10 objects -Only interfaces marked with `// @endpoint` are exposed. The server uses Intermock to parse TypeScript AST and Faker to generate realistic test data. Schemas are cached in memory for performance. +Only interfaces marked with `// @endpoint` are exposed. The server uses Intermock to parse TypeScript AST and Faker to generate realistic test data. + +**Constraint Processing**: When a request is made, the system: +1. Extracts JSDoc annotations from each field in the interface +2. Generates initial mock data using Intermock/Faker +3. Applies constraints (length, range, enum, pattern) to ensure valid test data +4. Caches schemas in memory for performance --- diff --git a/src/core/constrainedGenerator.ts b/src/core/constrainedGenerator.ts new file mode 100644 index 0000000..faf1ce6 --- /dev/null +++ b/src/core/constrainedGenerator.ts @@ -0,0 +1,179 @@ +import { faker } from '@faker-js/faker'; +import { FieldConstraint, FieldConstraints } from '../utils/constraintExtractor'; +import { + getStringLengthBounds, + getNumberBounds, + getEnumValues, + getPattern, +} from '../utils/constraintValidator'; + +/** + * Generates a constrained string value + */ +export function generateConstrainedString(constraints: FieldConstraint[]): string { + const enumValues = getEnumValues(constraints); + if (enumValues && enumValues.length > 0) { + return faker.helpers.arrayElement(enumValues); + } + + const pattern = getPattern(constraints); + if (pattern) { + // For patterns, try to generate matching string + return generateStringMatchingPattern(pattern); + } + + const { min, max } = getStringLengthBounds(constraints); + + // Generate a random string of appropriate length + const length = faker.number.int({ min, max }); + return faker.string.alphanumeric(length); +} + +/** + * Generates a constrained number value + */ +export function generateConstrainedNumber(constraints: FieldConstraint[]): number { + const enumValues = getEnumValues(constraints); + if (enumValues && enumValues.length > 0) { + const numericValues = enumValues.map((v) => parseFloat(v)).filter((v) => !isNaN(v)); + if (numericValues.length > 0) { + return faker.helpers.arrayElement(numericValues); + } + } + + const { min, max } = getNumberBounds(constraints); + // Use float generation when either bound is a decimal + if (min % 1 !== 0 || max % 1 !== 0) { + return faker.number.float({ min, max }); + } + return faker.number.int({ min, max }); +} + +/** + * Generates a string that matches a regex pattern. + * Uses heuristics for common patterns; falls back to alphanumeric for others. + */ +function generateStringMatchingPattern(pattern: RegExp): string { + const source = pattern.source; + + if (source.includes('[a-z]') || source === '[a-z]*') { + return faker.string.alpha({ length: 10 }); + } + if (source.includes('[0-9]') || source === '[0-9]*') { + return faker.string.numeric({ length: 10 }); + } + if (source.includes('[a-zA-Z0-9]')) { + return faker.string.alphanumeric({ length: 10 }); + } + if (source.includes('@') || source === '^[^@]+@[^@]+\\.[^@]+$') { + return faker.internet.email(); + } + if (source.includes('http') || source.includes('://')) { + return faker.internet.url(); + } + + return faker.string.alphanumeric(10); +} + +/** + * Applies constraints to generated mock data + * This function takes intermock-generated data and applies custom constraints + */ +export function applyConstraintsToMock( + mockData: Record, + fieldConstraints: FieldConstraints, + knownTypes: Record = {} +): Record { + const constrained = { ...mockData }; + + for (const [fieldName, constraints] of Object.entries(fieldConstraints)) { + if (fieldName in constrained) { + const currentValue = constrained[fieldName]; + const fieldConstraintsList = constraints as FieldConstraint[]; + + // Determine the field type from knownTypes or the actual runtime value + const actualType = knownTypes[fieldName] ?? typeof currentValue; + const isNumeric = actualType === 'number'; + + if (fieldConstraintsList.some((c) => c.type === 'enum')) { + const enumValues = getEnumValues(fieldConstraintsList); + if (enumValues && enumValues.length > 0) { + if (isNumeric) { + const numericValues = enumValues.map((v) => parseFloat(v)).filter((v) => !isNaN(v)); + constrained[fieldName] = + numericValues.length > 0 + ? faker.helpers.arrayElement(numericValues) + : faker.helpers.arrayElement(enumValues); + } else { + constrained[fieldName] = faker.helpers.arrayElement(enumValues); + } + } + } else if (isNumeric) { + const value = currentValue as number; + const minConstraint = fieldConstraintsList.find((c) => c.type === 'min'); + const maxConstraint = fieldConstraintsList.find((c) => c.type === 'max'); + const min = minConstraint ? (minConstraint.value as number) : value; + const max = maxConstraint ? (maxConstraint.value as number) : value; + + if (value < min || value > max) { + constrained[fieldName] = generateConstrainedNumber(fieldConstraintsList); + } + } else { + constrained[fieldName] = generateConstrainedString(fieldConstraintsList); + } + } + } + + return constrained; +} + +/** + * Generates a value for a field based on its type and constraints + */ +export function generateFieldValue( + _fieldName: string, + fieldType: string, + constraints: FieldConstraint[] = [] +): unknown { + // If we have constraints that hint at the type + const hasStringConstraints = constraints.some((c) => + ['minLength', 'maxLength', 'pattern'].includes(c.type) + ); + const hasNumberConstraints = constraints.some((c) => + ['min', 'max'].includes(c.type) + ); + const hasEnumConstraints = constraints.some((c) => c.type === 'enum'); + + if (hasEnumConstraints) { + const enumValues = getEnumValues(constraints); + if (enumValues && enumValues.length > 0) { + if (fieldType === 'number') { + const numericValues = enumValues.map((v) => parseFloat(v)).filter((v) => !isNaN(v)); + if (numericValues.length > 0) { + return faker.helpers.arrayElement(numericValues); + } + } + return faker.helpers.arrayElement(enumValues); + } + } + + // Generate based on constraints or field type + if (hasStringConstraints || fieldType === 'string') { + return generateConstrainedString(constraints); + } + + if (hasNumberConstraints || ['number', 'int', 'integer'].includes(fieldType)) { + return generateConstrainedNumber(constraints); + } + + if (fieldType === 'boolean') { + return faker.datatype.boolean(); + } + + if (fieldType === 'date' || fieldType === 'Date') { + return faker.date.recent(); + } + + // Default fallback + return generateConstrainedString(constraints); +} diff --git a/src/core/parser.ts b/src/core/parser.ts index ab70e47..36ae4bd 100644 --- a/src/core/parser.ts +++ b/src/core/parser.ts @@ -1,6 +1,8 @@ import * as intermock from 'intermock'; import * as fs from 'fs'; import { MockGenerationOptions } from '../types/config'; +import { extractConstraints } from '../utils/constraintExtractor'; +import { applyConstraintsToMock } from './constrainedGenerator'; /** * Generates mock data from a TypeScript interface @@ -29,12 +31,28 @@ export function generateMockFromInterface( }); // Intermock returns an object with the interface name as key - const mockData = output[interfaceName as keyof typeof output]; + let mockData = output[interfaceName as keyof typeof output]; if (!mockData) { throw new Error(`Interface "${interfaceName}" not found in file ${filePath}`); } + // Extract and apply JSDoc constraints from the interface + try { + const constraints = extractConstraints(filePath, interfaceName); + if (Object.keys(constraints).length > 0) { + mockData = applyConstraintsToMock( + mockData as Record, + constraints + ); + } + } catch (constraintError) { + // Log constraint extraction errors but don't fail the entire generation + console.warn( + `Warning: Failed to extract constraints for ${interfaceName}: ${constraintError instanceof Error ? constraintError.message : String(constraintError)}` + ); + } + return mockData as Record; } catch (error) { throw new Error( diff --git a/src/utils/constraintExtractor.ts b/src/utils/constraintExtractor.ts new file mode 100644 index 0000000..94c8a43 --- /dev/null +++ b/src/utils/constraintExtractor.ts @@ -0,0 +1,227 @@ +import * as fs from 'fs'; +import * as ts from 'typescript'; + +/** + * Represents a single constraint on a field + */ +export interface FieldConstraint { + type: 'minLength' | 'maxLength' | 'pattern' | 'min' | 'max' | 'enum' | 'custom'; + value: string | number | string[]; +} + +/** + * Represents all constraints for a single field + */ +export interface FieldConstraints { + [fieldName: string]: FieldConstraint[]; +} + +/** + * Extracts JSDoc annotations from a TypeScript file and returns constraints + * + * Supported annotations: + * - @minLength number + * - @maxLength number + * - @pattern regex_string + * - @min number + * - @max number + * - @enum value1,value2,value3 + */ +export function extractConstraints( + filePath: string, + interfaceName: string +): FieldConstraints { + const fileContent = fs.readFileSync(filePath, 'utf-8'); + const sourceFile = ts.createSourceFile( + filePath, + fileContent, + ts.ScriptTarget.Latest, + true, + ts.ScriptKind.TS + ); + + const constraints: FieldConstraints = {}; + + // Visit all nodes in the AST + visit(sourceFile, interfaceName, constraints, sourceFile); + + return constraints; +} + +/** + * Recursively visits the AST to find the interface and extract constraints + */ +function visit( + node: ts.Node, + interfaceName: string, + constraints: FieldConstraints, + sourceFile?: ts.SourceFile +): void { + // Look for interface declarations + if (ts.isInterfaceDeclaration(node) && node.name?.text === interfaceName) { + // Process each property of the interface + node.members.forEach((member) => { + if (ts.isPropertySignature(member) && member.name) { + // Use .text for identifiers/string literals to avoid wrapping quotes + const propName = + ts.isIdentifier(member.name) || ts.isStringLiteral(member.name) + ? member.name.text + : member.name.getText(); + const fieldConstraints = extractJSDocConstraints(member, sourceFile); + + if (fieldConstraints.length > 0) { + constraints[propName] = fieldConstraints; + } + } + }); + return; + } + + // Recursively visit children + ts.forEachChild(node, (child) => visit(child, interfaceName, constraints, sourceFile)); +} + +/** + * Extracts JSDoc constraints from a property + */ +function extractJSDocConstraints(node: ts.PropertySignature, sourceFile?: ts.SourceFile): FieldConstraint[] { + const constraints: FieldConstraint[] = []; + + const jsDocs = ts.getJSDocCommentsAndTags(node); + + jsDocs.forEach((doc) => { + // doc can be a JSDocComment or a JSDocTag + if (typeof doc !== 'object') return; + + if ('tags' in doc && Array.isArray(doc.tags)) { + // It's a JSDocComment + doc.tags.forEach((tag) => { + const constraint = parseJSDocTag(tag, sourceFile); + if (constraint) { + constraints.push(constraint); + } + }); + } else if ('tagName' in doc) { + // It's a JSDocTag directly + const constraint = parseJSDocTag(doc as ts.JSDocTag, sourceFile); + if (constraint) { + constraints.push(constraint); + } + } + }); + + return constraints; +} + +/** + * Parses a single JSDoc tag and returns the constraint + */ +function parseJSDocTag(tag: ts.JSDocTag, sourceFile?: ts.SourceFile): FieldConstraint | null { + const tagName = tag.tagName.text; + if (!tagName) return null; + + // Resolve the comment to a plain string. + // For @enum, always read from source text because the TS parser treats the + // first enum value as a type annotation and truncates the rest. + let commentStr: string | undefined; + if (tagName === 'enum' && sourceFile) { + const tagText = tag.getFullText(sourceFile); + const match = tagText.match(/@enum\s+([^*@]+?)(?=\s*(?:\*\/|@|$))/); + commentStr = match?.[1]?.trim(); + } else { + const raw = tag.comment; + // Normalize NodeArray to a plain string + if (Array.isArray(raw)) { + commentStr = (raw as ts.NodeArray).map((node) => node.text).join(''); + } else if (typeof raw === 'string') { + commentStr = raw; + } + + // Fall back to extracting from source text when comment is empty + if (!commentStr && sourceFile) { + const tagText = tag.getFullText(sourceFile); + const match = tagText.match(new RegExp(`@${tagName}\\s+([^\\*@]+?)(?=\\s*(?:\\*\\/|@|$))`)); + commentStr = match?.[1]?.trim() ?? ''; + } + } + + switch (tagName) { + case 'minLength': { + const value = extractNumericValue(commentStr); + if (value !== null) return { type: 'minLength', value }; + break; + } + case 'maxLength': { + const value = extractNumericValue(commentStr); + if (value !== null) return { type: 'maxLength', value }; + break; + } + case 'min': { + const value = extractNumericValue(commentStr); + if (value !== null) return { type: 'min', value }; + break; + } + case 'max': { + const value = extractNumericValue(commentStr); + if (value !== null) return { type: 'max', value }; + break; + } + case 'pattern': { + const value = extractStringValue(commentStr); + if (value) return { type: 'pattern', value }; + break; + } + case 'enum': { + const values = extractEnumValues(commentStr); + if (values.length > 0) return { type: 'enum', value: values }; + break; + } + } + + return null; +} + +/** + * Extracts a numeric value from a JSDoc comment + */ +function extractNumericValue(comment: string | undefined): number | null { + if (!comment) return null; + + const match = comment.match(/-?\d+(\.\d+)?/); + if (match) { + return parseFloat(match[0]); + } + + return null; +} + +/** + * Extracts a string value from a JSDoc comment + */ +function extractStringValue(comment: string | undefined): string | null { + if (!comment) return null; + + // Try to extract quoted string + const match = comment.match(/['"`]([^'"`]+)['"`]/); + if (match && match[1]) { + return match[1]; + } + + // Or just trim the comment + const trimmed = comment.trim(); + return trimmed.length > 0 ? trimmed : null; +} + +/** + * Extracts enum values from a JSDoc comment + */ +function extractEnumValues(comment: string | undefined): string[] { + if (!comment) return []; + + // Split by comma and trim each value + return comment + .trim() + .split(',') + .map((v) => v.trim()) + .filter((v) => v.length > 0); +} diff --git a/src/utils/constraintValidator.ts b/src/utils/constraintValidator.ts new file mode 100644 index 0000000..5ef5fd9 --- /dev/null +++ b/src/utils/constraintValidator.ts @@ -0,0 +1,100 @@ +import { FieldConstraint } from './constraintExtractor'; + +/** + * Validates a value against a single constraint + */ +export function validateConstraint( + value: string | number, + constraint: FieldConstraint +): boolean { + switch (constraint.type) { + case 'minLength': + return typeof value === 'string' && value.length >= (constraint.value as number); + + case 'maxLength': + return typeof value === 'string' && value.length <= (constraint.value as number); + + case 'min': + return typeof value === 'number' && value >= (constraint.value as number); + + case 'max': + return typeof value === 'number' && value <= (constraint.value as number); + + case 'pattern': { + const pattern = new RegExp(constraint.value as string); + return typeof value === 'string' && pattern.test(value); + } + + case 'enum': + return (constraint.value as string[]).includes(String(value)); + + default: + return true; + } +} + +/** + * Validates a value against all constraints + */ +export function validateAllConstraints( + value: string | number, + constraints: FieldConstraint[] +): boolean { + return constraints.every((constraint) => validateConstraint(value, constraint)); +} + +/** + * Gets constraints of a specific type + */ +export function getConstraintByType( + constraints: FieldConstraint[], + type: FieldConstraint['type'] +): FieldConstraint | null { + return constraints.find((c) => c.type === type) || null; +} + +/** + * Helper to get min/max length constraints for strings + */ +export function getStringLengthBounds( + constraints: FieldConstraint[] +): { min: number; max: number } { + const minConstraint = getConstraintByType(constraints, 'minLength'); + const maxConstraint = getConstraintByType(constraints, 'maxLength'); + + return { + min: minConstraint ? (minConstraint.value as number) : 0, + max: maxConstraint ? (maxConstraint.value as number) : 100, + }; +} + +/** + * Helper to get min/max constraints for numbers + */ +export function getNumberBounds( + constraints: FieldConstraint[] +): { min: number; max: number } { + const minConstraint = getConstraintByType(constraints, 'min'); + const maxConstraint = getConstraintByType(constraints, 'max'); + + return { + min: minConstraint ? (minConstraint.value as number) : 0, + max: maxConstraint ? (maxConstraint.value as number) : 1000, + }; +} + +/** + * Get enum values if constraint exists + */ +export function getEnumValues(constraints: FieldConstraint[]): string[] | null { + const enumConstraint = getConstraintByType(constraints, 'enum'); + return enumConstraint ? (enumConstraint.value as string[]) : null; +} + +/** + * Get pattern if constraint exists + */ +export function getPattern(constraints: FieldConstraint[]): RegExp | null { + const patternConstraint = getConstraintByType(constraints, 'pattern'); + return patternConstraint ? new RegExp(patternConstraint.value as string) : null; +} diff --git a/test-types/sample.ts b/test-types/sample.ts index 3222b1b..6f5086a 100644 --- a/test-types/sample.ts +++ b/test-types/sample.ts @@ -11,3 +11,13 @@ export interface Product { title: string; price: number; } + +// @endpoint +export interface Badge { + /** @maxLength 10 */ + label: string; + /** @min 1 @max 5 */ + level: number; + /** @enum ACTIVE,INACTIVE,PENDING */ + status: string; +} diff --git a/tests/core/constrainedGenerator.test.ts b/tests/core/constrainedGenerator.test.ts new file mode 100644 index 0000000..21e93bb --- /dev/null +++ b/tests/core/constrainedGenerator.test.ts @@ -0,0 +1,222 @@ +import { + generateConstrainedString, + generateConstrainedNumber, + applyConstraintsToMock, + generateFieldValue, +} from '../../src/core/constrainedGenerator'; +import { FieldConstraint, FieldConstraints } from '../../src/utils/constraintExtractor'; + +describe('constrainedGenerator', () => { + describe('generateConstrainedString', () => { + it('should generate string within maxLength constraint', () => { + const constraints: FieldConstraint[] = [{ type: 'maxLength', value: 10 }]; + + for (let i = 0; i < 10; i++) { + const value = generateConstrainedString(constraints); + expect(typeof value).toBe('string'); + expect(value.length).toBeLessThanOrEqual(10); + } + }); + + it('should generate string within minLength and maxLength', () => { + const constraints: FieldConstraint[] = [ + { type: 'minLength', value: 5 }, + { type: 'maxLength', value: 15 }, + ]; + + for (let i = 0; i < 10; i++) { + const value = generateConstrainedString(constraints); + expect(value.length).toBeGreaterThanOrEqual(5); + expect(value.length).toBeLessThanOrEqual(15); + } + }); + + it('should generate value from enum', () => { + const constraints: FieldConstraint[] = [ + { type: 'enum', value: ['RED', 'GREEN', 'BLUE'] }, + ]; + + const value = generateConstrainedString(constraints); + expect(['RED', 'GREEN', 'BLUE']).toContain(value); + }); + + it('should generate string matching pattern', () => { + const constraints: FieldConstraint[] = [ + { type: 'pattern', value: '[a-z]*' }, + ]; + + const value = generateConstrainedString(constraints); + expect(typeof value).toBe('string'); + // Pattern-based generation may not perfectly match, but shouldn't error + expect(value.length).toBeGreaterThan(0); + }); + }); + + describe('generateConstrainedNumber', () => { + it('should generate number within min and max bounds', () => { + const constraints: FieldConstraint[] = [ + { type: 'min', value: 10 }, + { type: 'max', value: 20 }, + ]; + + for (let i = 0; i < 10; i++) { + const value = generateConstrainedNumber(constraints); + expect(typeof value).toBe('number'); + expect(value).toBeGreaterThanOrEqual(10); + expect(value).toBeLessThanOrEqual(20); + } + }); + + it('should generate value from enum numbers', () => { + const constraints: FieldConstraint[] = [ + { type: 'enum', value: ['1', '2', '3'] }, + ]; + + const value = generateConstrainedNumber(constraints); + expect([1, 2, 3]).toContain(value); + }); + + it('should handle only min constraint', () => { + const constraints: FieldConstraint[] = [{ type: 'min', value: 100 }]; + + const value = generateConstrainedNumber(constraints); + expect(value).toBeGreaterThanOrEqual(100); + }); + + it('should handle only max constraint', () => { + const constraints: FieldConstraint[] = [{ type: 'max', value: 50 }]; + + const value = generateConstrainedNumber(constraints); + expect(value).toBeLessThanOrEqual(50); + }); + }); + + describe('applyConstraintsToMock', () => { + it('should apply string constraints to mock data', () => { + const mockData = { + label: 'this is a very long label that exceeds the maximum length', + value: 123, + }; + + const constraints: FieldConstraints = { + label: [{ type: 'maxLength', value: 10 }], + }; + + const result = applyConstraintsToMock(mockData, constraints); + + expect(result.label).toBeDefined(); + expect(typeof result.label).toBe('string'); + expect((result.label as string).length).toBeLessThanOrEqual(10); + expect(result.value).toBe(123); // Unchanged + }); + + it('should apply number constraints to mock data', () => { + const mockData = { + id: 1, + rating: 500, + }; + + const constraints: FieldConstraints = { + rating: [ + { type: 'min', value: 1 }, + { type: 'max', value: 5 }, + ], + }; + + const result = applyConstraintsToMock(mockData, constraints); + + expect(result.rating).toBeDefined(); + expect(typeof result.rating).toBe('number'); + expect(result.rating).toBeGreaterThanOrEqual(1); + expect(result.rating).toBeLessThanOrEqual(5); + }); + + it('should apply enum constraints to mock data', () => { + const mockData = { + status: 'unknown', + }; + + const constraints: FieldConstraints = { + status: [{ type: 'enum', value: ['ACTIVE', 'INACTIVE', 'PENDING'] }], + }; + + const result = applyConstraintsToMock(mockData, constraints); + + expect(['ACTIVE', 'INACTIVE', 'PENDING']).toContain(result.status); + }); + + it('should ignore fields not in constraints', () => { + const mockData = { + name: 'John', + email: 'john@example.com', + }; + + const constraints: FieldConstraints = { + name: [{ type: 'maxLength', value: 5 }], + }; + + const result = applyConstraintsToMock(mockData, constraints); + + expect(result.email).toBe('john@example.com'); // Unchanged + }); + + it('should handle empty constraints', () => { + const mockData = { + id: 1, + name: 'test', + }; + + const result = applyConstraintsToMock(mockData, {}); + + expect(result).toEqual(mockData); + }); + }); + + describe('generateFieldValue', () => { + it('should generate string without constraints', () => { + const value = generateFieldValue('name', 'string'); + expect(typeof value).toBe('string'); + }); + + it('should generate number without constraints', () => { + const value = generateFieldValue('count', 'number'); + expect(typeof value).toBe('number'); + }); + + it('should generate boolean without constraints', () => { + const value = generateFieldValue('active', 'boolean'); + expect(typeof value).toBe('boolean'); + }); + + it('should apply string constraints to generated value', () => { + const constraints: FieldConstraint[] = [{ type: 'maxLength', value: 8 }]; + + for (let i = 0; i < 5; i++) { + const value = generateFieldValue('code', 'string', constraints); + expect((value as string).length).toBeLessThanOrEqual(8); + } + }); + + it('should apply number constraints to generated value', () => { + const constraints: FieldConstraint[] = [ + { type: 'min', value: 1 }, + { type: 'max', value: 10 }, + ]; + + for (let i = 0; i < 5; i++) { + const value = generateFieldValue('level', 'number', constraints); + expect(value).toBeGreaterThanOrEqual(1); + expect(value).toBeLessThanOrEqual(10); + } + }); + + it('should respect enum constraint', () => { + const constraints: FieldConstraint[] = [ + { type: 'enum', value: ['DRAFT', 'PUBLISHED'] }, + ]; + + const value = generateFieldValue('state', 'string', constraints); + expect(['DRAFT', 'PUBLISHED']).toContain(value); + }); + }); +}); diff --git a/tests/core/parser.test.ts b/tests/core/parser.test.ts index ba895a6..6a0ce49 100644 --- a/tests/core/parser.test.ts +++ b/tests/core/parser.test.ts @@ -107,6 +107,48 @@ export interface Product { }); }); + describe('generateMockFromInterface with JSDoc constraints', () => { + const constrainedFile = path.join(testDir, 'constrained-interface.ts'); + + beforeAll(() => { + fs.writeFileSync( + constrainedFile, + `export interface Badge { + /** @maxLength 10 */ + label: string; + /** @min 1 @max 5 */ + level: number; + /** @enum ACTIVE,INACTIVE,PENDING */ + status: string; +}` + ); + }); + + it('should respect @maxLength on string fields', () => { + for (let i = 0; i < 5; i++) { + const mock = generateMockFromInterface(constrainedFile, 'Badge'); + expect(typeof mock.label).toBe('string'); + expect((mock.label as string).length).toBeLessThanOrEqual(10); + } + }); + + it('should respect @min and @max on number fields', () => { + for (let i = 0; i < 5; i++) { + const mock = generateMockFromInterface(constrainedFile, 'Badge'); + expect(typeof mock.level).toBe('number'); + expect(mock.level).toBeGreaterThanOrEqual(1); + expect(mock.level).toBeLessThanOrEqual(5); + } + }); + + it('should respect @enum on string fields', () => { + for (let i = 0; i < 5; i++) { + const mock = generateMockFromInterface(constrainedFile, 'Badge'); + expect(['ACTIVE', 'INACTIVE', 'PENDING']).toContain(mock.status); + } + }); + }); + describe('generateMockArray', () => { it('should generate array of mocks', () => { const mocks = generateMockArray(testFile, 'User'); diff --git a/tests/utils/constraintExtractor.test.ts b/tests/utils/constraintExtractor.test.ts new file mode 100644 index 0000000..ae9cf23 --- /dev/null +++ b/tests/utils/constraintExtractor.test.ts @@ -0,0 +1,152 @@ +import { extractConstraints, FieldConstraint } from '../../src/utils/constraintExtractor'; +import * as path from 'path'; +import * as fs from 'fs'; +import * as os from 'os'; + +describe('constraintExtractor', () => { + let tempDir: string; + let testFile: string; + + beforeEach(() => { + tempDir = fs.mkdtempSync(path.join(os.tmpdir(), 'constraint-test-')); + testFile = path.join(tempDir, 'test.ts'); + }); + + afterEach(() => { + fs.rmSync(tempDir, { recursive: true }); + }); + + it('should extract maxLength constraint from JSDoc', () => { + const content = ` + // @endpoint + export interface Badge { + /** @maxLength 10 */ + label: string; + } + `; + fs.writeFileSync(testFile, content); + + const constraints = extractConstraints(testFile, 'Badge'); + + expect(constraints.label).toBeDefined(); + expect(constraints.label).toHaveLength(1); + expect(constraints.label![0]!.type).toBe('maxLength'); + expect(constraints.label![0]!.value).toBe(10); + }); + + it('should extract minLength constraint from JSDoc', () => { + const content = ` + // @endpoint + export interface Product { + /** @minLength 5 */ + sku: string; + } + `; + fs.writeFileSync(testFile, content); + + const constraints = extractConstraints(testFile, 'Product'); + + expect(constraints.sku).toBeDefined(); + expect(constraints.sku![0]!.type).toBe('minLength'); + expect(constraints.sku![0]!.value).toBe(5); + }); + + it('should extract min and max constraints for numbers', () => { + const content = ` + // @endpoint + export interface Rating { + /** @min 1 @max 5 */ + stars: number; + } + `; + fs.writeFileSync(testFile, content); + + const constraints = extractConstraints(testFile, 'Rating'); + + expect(constraints.stars).toHaveLength(2); + const types = constraints.stars!.map((c: FieldConstraint) => c.type); + expect(types).toContain('min'); + expect(types).toContain('max'); + }); + + it('should extract enum constraint', () => { + const content = ` + // @endpoint + export interface Status { + /** @enum ACTIVE,INACTIVE,PENDING */ + state: string; + } + `; + fs.writeFileSync(testFile, content); + + const constraints = extractConstraints(testFile, 'Status'); + + expect(constraints.state).toBeDefined(); + expect(constraints.state![0]!.type).toBe('enum'); + expect(constraints.state![0]!.value).toEqual(['ACTIVE', 'INACTIVE', 'PENDING']); + }); + + it('should extract pattern constraint', () => { + const content = ` + // @endpoint + export interface Email { + /** @pattern ^[a-zA-Z0-9._%+-]+@[a-zA-Z0-9.-]+\\.[a-zA-Z]{2,}$ */ + address: string; + } + `; + fs.writeFileSync(testFile, content); + + const constraints = extractConstraints(testFile, 'Email'); + + expect(constraints.address).toBeDefined(); + expect(constraints.address![0]!.type).toBe('pattern'); + expect(typeof constraints.address![0]!.value).toBe('string'); + }); + + it('should extract multiple constraints on one field', () => { + const content = ` + // @endpoint + export interface User { + /** @minLength 3 @maxLength 50 */ + username: string; + } + `; + fs.writeFileSync(testFile, content); + + const constraints = extractConstraints(testFile, 'User'); + + expect(constraints.username).toHaveLength(2); + const types = constraints.username!.map((c: FieldConstraint) => c.type); + expect(types).toContain('minLength'); + expect(types).toContain('maxLength'); + }); + + it('should return empty constraints for interface without JSDoc annotations', () => { + const content = ` + // @endpoint + export interface Simple { + id: number; + name: string; + } + `; + fs.writeFileSync(testFile, content); + + const constraints = extractConstraints(testFile, 'Simple'); + + expect(Object.keys(constraints)).toHaveLength(0); + }); + + it('should return empty object for non-existent interface', () => { + const content = ` + // @endpoint + export interface Existing { + id: number; + } + `; + fs.writeFileSync(testFile, content); + + const constraints = extractConstraints(testFile, 'NonExistent'); + + expect(Object.keys(constraints)).toHaveLength(0); + }); +}); diff --git a/tests/utils/constraintValidator.test.ts b/tests/utils/constraintValidator.test.ts new file mode 100644 index 0000000..5690755 --- /dev/null +++ b/tests/utils/constraintValidator.test.ts @@ -0,0 +1,176 @@ +import { + validateConstraint, + validateAllConstraints, + getStringLengthBounds, + getNumberBounds, + getEnumValues, + getPattern, +} from '../../src/utils/constraintValidator'; +import { FieldConstraint } from '../../src/utils/constraintExtractor'; + +describe('constraintValidator', () => { + describe('validateConstraint', () => { + it('should validate maxLength constraint', () => { + const constraint: FieldConstraint = { type: 'maxLength', value: 10 }; + + expect(validateConstraint('hello', constraint)).toBe(true); + expect(validateConstraint('hello world', constraint)).toBe(false); + expect(validateConstraint('1234567890', constraint)).toBe(true); + }); + + it('should validate minLength constraint', () => { + const constraint: FieldConstraint = { type: 'minLength', value: 3 }; + + expect(validateConstraint('ab', constraint)).toBe(false); + expect(validateConstraint('abc', constraint)).toBe(true); + expect(validateConstraint('abcdef', constraint)).toBe(true); + }); + + it('should validate min constraint for numbers', () => { + const constraint: FieldConstraint = { type: 'min', value: 5 }; + + expect(validateConstraint(3, constraint)).toBe(false); + expect(validateConstraint(5, constraint)).toBe(true); + expect(validateConstraint(10, constraint)).toBe(true); + }); + + it('should validate max constraint for numbers', () => { + const constraint: FieldConstraint = { type: 'max', value: 100 }; + + expect(validateConstraint(50, constraint)).toBe(true); + expect(validateConstraint(100, constraint)).toBe(true); + expect(validateConstraint(101, constraint)).toBe(false); + }); + + it('should validate pattern constraint', () => { + const constraint: FieldConstraint = { type: 'pattern', value: '^[a-z]+$' }; + + expect(validateConstraint('abc', constraint)).toBe(true); + expect(validateConstraint('ABC', constraint)).toBe(false); + expect(validateConstraint('abc123', constraint)).toBe(false); + }); + + it('should validate enum constraint', () => { + const constraint: FieldConstraint = { type: 'enum', value: ['RED', 'GREEN', 'BLUE'] }; + + expect(validateConstraint('RED', constraint)).toBe(true); + expect(validateConstraint('GREEN', constraint)).toBe(true); + expect(validateConstraint('YELLOW', constraint)).toBe(false); + expect(validateConstraint(123, constraint)).toBe(false); + }); + }); + + describe('validateAllConstraints', () => { + it('should validate all constraints pass', () => { + const constraints: FieldConstraint[] = [ + { type: 'minLength', value: 3 }, + { type: 'maxLength', value: 10 }, + ]; + + expect(validateAllConstraints('hello', constraints)).toBe(true); + expect(validateAllConstraints('ab', constraints)).toBe(false); + expect(validateAllConstraints('hello world', constraints)).toBe(false); + }); + + it('should return true for empty constraints array', () => { + expect(validateAllConstraints('anything', [])).toBe(true); + }); + + it('should validate mixed constraints', () => { + const constraints: FieldConstraint[] = [ + { type: 'pattern', value: '^[a-z0-9]+$' }, + { type: 'maxLength', value: 20 }, + ]; + + expect(validateAllConstraints('abc123', constraints)).toBe(true); + expect(validateAllConstraints('ABC123', constraints)).toBe(false); + expect(validateAllConstraints('a' + 'x'.repeat(20), constraints)).toBe(false); + }); + }); + + describe('getStringLengthBounds', () => { + it('should extract min and max length bounds', () => { + const constraints: FieldConstraint[] = [ + { type: 'minLength', value: 5 }, + { type: 'maxLength', value: 15 }, + ]; + + const bounds = getStringLengthBounds(constraints); + expect(bounds.min).toBe(5); + expect(bounds.max).toBe(15); + }); + + it('should return defaults when no constraints', () => { + const bounds = getStringLengthBounds([]); + expect(bounds.min).toBe(0); + expect(bounds.max).toBe(100); + }); + + it('should handle only minLength constraint', () => { + const constraints: FieldConstraint[] = [{ type: 'minLength', value: 10 }]; + const bounds = getStringLengthBounds(constraints); + expect(bounds.min).toBe(10); + expect(bounds.max).toBe(100); + }); + + it('should handle only maxLength constraint', () => { + const constraints: FieldConstraint[] = [{ type: 'maxLength', value: 50 }]; + const bounds = getStringLengthBounds(constraints); + expect(bounds.min).toBe(0); + expect(bounds.max).toBe(50); + }); + }); + + describe('getNumberBounds', () => { + it('should extract min and max bounds', () => { + const constraints: FieldConstraint[] = [ + { type: 'min', value: 1 }, + { type: 'max', value: 10 }, + ]; + + const bounds = getNumberBounds(constraints); + expect(bounds.min).toBe(1); + expect(bounds.max).toBe(10); + }); + + it('should return defaults when no constraints', () => { + const bounds = getNumberBounds([]); + expect(bounds.min).toBe(0); + expect(bounds.max).toBe(1000); + }); + }); + + describe('getEnumValues', () => { + it('should extract enum values', () => { + const constraints: FieldConstraint[] = [ + { type: 'enum', value: ['A', 'B', 'C'] }, + ]; + + const values = getEnumValues(constraints); + expect(values).toEqual(['A', 'B', 'C']); + }); + + it('should return null when no enum constraint', () => { + const values = getEnumValues([{ type: 'maxLength', value: 10 }]); + expect(values).toBeNull(); + }); + }); + + describe('getPattern', () => { + it('should extract pattern as regex', () => { + const constraints: FieldConstraint[] = [ + { type: 'pattern', value: '^[a-z]+$' }, + ]; + + const pattern = getPattern(constraints); + expect(pattern).not.toBeNull(); + expect(pattern?.test('abc')).toBe(true); + expect(pattern?.test('ABC')).toBe(false); + }); + + it('should return null when no pattern constraint', () => { + const pattern = getPattern([{ type: 'maxLength', value: 10 }]); + expect(pattern).toBeNull(); + }); + }); +});