Skip to content

Commit 57b8df0

Browse files
committed
Implement first version of service test generation
1 parent 9689247 commit 57b8df0

File tree

13 files changed

+774
-163
lines changed

13 files changed

+774
-163
lines changed

src/core/utils.ts

Lines changed: 12 additions & 10 deletions
Original file line numberDiff line numberDiff line change
@@ -39,11 +39,13 @@ export function singular(str: string): string {
3939
function normalizeString(str: string): string {
4040
if (!str) return '';
4141
return str
42-
.replace(/[^a-zA-Z0-9\s_-]/g, ' ') // Remove non-alphanumeric characters
43-
.replace(/^[_-]+|[-_]+$/g, '') // Trim leading/trailing separators
44-
.replace(/([a-z])([A-Z])/g, '$1 $2') // Split camelCase
45-
.replace(/[_-]+/g, ' ') // Replace separators with spaces
46-
.replace(/\s+/g, ' ') // Collapse whitespace
42+
.replace(/[^a-zA-Z0-9\s_-]/g, ' ')
43+
.replace(/^[_-]+|[-_]+$/g, '')
44+
// Handles helloWorld -> hello World, and MyAPI -> My API, and OpId -> Op Id
45+
.replace(/([a-z0-9])([A-Z])/g, '$1 $2')
46+
.replace(/([A-Z])([A-Z][a-z])/g, '$1 $2')
47+
.replace(/[_-]+/g, ' ')
48+
.replace(/\s+/g, ' ')
4749
.trim()
4850
.toLowerCase();
4951
}
@@ -284,12 +286,12 @@ export function extractPaths(swaggerPaths: { [p: string]: Path } | undefined): P
284286

285287
paths.push({
286288
path,
289+
// FIX: THE CRITICAL CHANGE IS HERE.
290+
// The `operation` object contains `operationId`, `summary`, etc.
291+
// We must spread it to include all its properties in the final PathInfo.
292+
...operation,
287293
method: method.toUpperCase(),
288-
operationId: operation.operationId,
289-
summary: operation.summary,
290-
description: operation.description,
291-
tags: operation.tags || [],
292-
consumes: operation.consumes || [],
294+
// redundant properties will be overwritten correctly by the spread.
293295
parameters,
294296
requestBody: requestBody as RequestBody | undefined,
295297
responses: normalizedResponses,

src/index.ts

Lines changed: 4 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -27,7 +27,9 @@ export async function generateFromConfig(
2727
project?: Project,
2828
testConfig?: TestGeneratorConfig
2929
): Promise<void> {
30-
const isTestEnv = !!project && !!testConfig;
30+
// MODIFICATION: The save call is now ONLY skipped if testConfig is provided.
31+
// This allows passing a project for in-memory use while still triggering save().
32+
const isTestEnv = !!testConfig;
3133

3234
const activeProject = project || new Project({
3335
compilerOptions: {
@@ -57,6 +59,7 @@ export async function generateFromConfig(
5759

5860
await emitClientLibrary(config.output, swaggerParser, config, activeProject);
5961

62+
// This block is now reachable in our test.
6063
if (!isTestEnv) {
6164
await activeProject.save();
6265
}

src/service/emit/admin/resource-discovery.ts

Lines changed: 134 additions & 90 deletions
Large diffs are not rendered by default.

src/service/emit/orchestrator.ts

Lines changed: 8 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -16,6 +16,7 @@ import { OAuthHelperGenerator } from './utility/oauth-helper.generator.js';
1616
import { BaseInterceptorGenerator } from './utility/base-interceptor.generator.js';
1717
import { ProviderGenerator } from './utility/provider.generator.js';
1818
import { MainIndexGenerator, ServiceIndexGenerator } from './utility/index.generator.js';
19+
import { ServiceTestGenerator } from "./test/service-test.generator.js";
1920

2021
/**
2122
* Orchestrates the entire code generation process for the Angular client library.
@@ -71,8 +72,13 @@ export async function emitClientLibrary(outputRoot: string, parser: SwaggerParse
7172
console.log('✅ Utilities and providers generated.');
7273
// Stub for Service Test Generation
7374
if (config.options.generateServiceTests ?? true) {
74-
console.log('📝 Test generation for services is stubbed.');
75-
// Future implementation: new ServiceTestGenerator(parser, project, config).generate(outputRoot);
75+
console.log('📝 Generating tests for services...');
76+
const testGenerator = new ServiceTestGenerator(parser, project, config);
77+
const controllerGroupsForTest = groupPathsByController(parser);
78+
for (const [controllerName, operations] of Object.entries(controllerGroupsForTest)) {
79+
testGenerator.generateServiceTestFile(controllerName, operations, servicesDir);
80+
}
81+
console.log('✅ Service tests generated.');
7682
}
7783

7884
if (config.options.admin) {
Lines changed: 84 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,84 @@
1+
import { SwaggerParser } from '../../../core/parser.js';
2+
import { SwaggerDefinition } from '../../../core/types.js';
3+
import { pascalCase } from '../../../core/utils.js';
4+
5+
/**
6+
* Generates mock data objects from OpenAPI schemas for use in tests.
7+
*/
8+
export class MockDataGenerator {
9+
constructor(private parser: SwaggerParser) {}
10+
11+
/**
12+
* Generates a TypeScript code string representing a mock object for a given schema.
13+
* @param schemaName The PascalCase name of the schema (e.g., 'User').
14+
* @returns A string of the mock object, e.g., `{ id: '1', name: 'user-name' }`.
15+
*/
16+
public generate(schemaName: string): string {
17+
const schema = this.parser.schemas.find(s => s.name === schemaName)?.definition;
18+
if (!schema) {
19+
return '{}';
20+
}
21+
const mockObject = this.generateValue(schema, new Set());
22+
// Pretty-print the object string
23+
return JSON.stringify(mockObject, null, 2);
24+
}
25+
26+
private generateValue(schema: SwaggerDefinition, visited: Set<SwaggerDefinition>): any {
27+
if (schema.example) {
28+
return schema.example;
29+
}
30+
31+
if (visited.has(schema)) {
32+
// Avoid infinite recursion
33+
return {};
34+
}
35+
visited.add(schema);
36+
37+
if (schema.allOf) {
38+
const combined = schema.allOf.reduce((acc, subSchemaRef) => {
39+
const subSchema = this.parser.resolve(subSchemaRef);
40+
if (!subSchema) return acc;
41+
return { ...acc, ...this.generateValue(subSchema, visited) };
42+
}, {});
43+
visited.delete(schema);
44+
return combined;
45+
}
46+
47+
if (schema.$ref) {
48+
const resolved = this.parser.resolve(schema);
49+
const result = resolved ? this.generateValue(resolved, visited) : {};
50+
visited.delete(schema);
51+
return result;
52+
}
53+
54+
switch (schema.type) {
55+
case 'string':
56+
if (schema.format === 'date-time' || schema.format === 'date') return new Date().toISOString();
57+
if (schema.format === 'email') return '[email protected]';
58+
if (schema.format === 'uuid') return '123e4567-e89b-12d3-a456-426614174000';
59+
return 'string-value';
60+
case 'number':
61+
case 'integer':
62+
return schema.minimum ?? 123;
63+
case 'boolean':
64+
return true;
65+
case 'array':
66+
if (schema.items && !Array.isArray(schema.items)) {
67+
return [this.generateValue(schema.items as SwaggerDefinition, visited)];
68+
}
69+
return [];
70+
case 'object':
71+
const obj: Record<string, any> = {};
72+
if (schema.properties) {
73+
for (const [propName, propSchema] of Object.entries(schema.properties)) {
74+
obj[propName] = this.generateValue(propSchema, visited);
75+
}
76+
}
77+
visited.delete(schema);
78+
return obj;
79+
default:
80+
visited.delete(schema);
81+
return null;
82+
}
83+
}
84+
}
Lines changed: 185 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,185 @@
1+
import * as path from 'path';
2+
import { Project, ClassDeclaration, SourceFile } from 'ts-morph';
3+
import { SwaggerParser } from '../../../core/parser.js';
4+
import { GeneratorConfig, PathInfo } from '../../../core/types.js';
5+
import {
6+
camelCase,
7+
pascalCase,
8+
getBasePathTokenName,
9+
isDataTypeInterface,
10+
getTypeScriptType,
11+
} from '../../../core/utils.js';
12+
import { MockDataGenerator } from './mock-data.generator.js';
13+
14+
export class ServiceTestGenerator {
15+
private mockDataGenerator: MockDataGenerator;
16+
17+
constructor(
18+
private readonly parser: SwaggerParser,
19+
private readonly project: Project,
20+
private readonly config: GeneratorConfig,
21+
) {
22+
this.mockDataGenerator = new MockDataGenerator(parser);
23+
}
24+
25+
public generateServiceTestFile(controllerName: string, operations: PathInfo[], outputDir: string): void {
26+
const serviceName = `${pascalCase(controllerName)}Service`;
27+
const testFileName = `${camelCase(controllerName)}.service.spec.ts`;
28+
const testFilePath = path.join(outputDir, testFileName);
29+
30+
const sourceFile = this.project.createSourceFile(testFilePath, '', { overwrite: true });
31+
32+
const modelImports = this.collectModelImports(operations);
33+
this.addImports(sourceFile, serviceName, Array.from(modelImports));
34+
35+
sourceFile.addStatements([
36+
`describe('${serviceName}', () => {`,
37+
` let service: ${serviceName};`,
38+
` let httpMock: HttpTestingController;`,
39+
'',
40+
` beforeEach(() => {`,
41+
` TestBed.configureTestingModule({`,
42+
` imports: [HttpClientTestingModule],`,
43+
` providers: [`,
44+
` ${serviceName},`,
45+
` { provide: ${getBasePathTokenName(this.config.clientName)}, useValue: '/api/v1' }`,
46+
` ]`,
47+
` });`,
48+
` service = TestBed.inject(${serviceName});`,
49+
` httpMock = TestBed.inject(HttpTestingController);`,
50+
` });`,
51+
'',
52+
` afterEach(() => {`,
53+
` httpMock.verify();`,
54+
` });`,
55+
'',
56+
` it('should be created', () => {`,
57+
` expect(service).toBeTruthy();`,
58+
` });`,
59+
...this.generateMethodTests(operations),
60+
`});`,
61+
]);
62+
63+
sourceFile.formatText();
64+
}
65+
66+
private generateMethodTests(operations: PathInfo[]): string[] {
67+
const tests: string[] = [];
68+
for (const op of operations) {
69+
if (!op.methodName) continue;
70+
71+
const { responseModel, responseType, bodyModel } = this.getMethodTypes(op);
72+
const params = op.parameters?.map(p => ({
73+
name: camelCase(p.name),
74+
value: typeof p.schema?.type === 'number' ? '123' : `'test-${p.name}'`,
75+
})) ?? [];
76+
const bodyParam = op.requestBody?.content?.['application/json']
77+
? { name: bodyModel ? camelCase(bodyModel) : 'body', model: bodyModel }
78+
: null;
79+
80+
const allArgs = [
81+
...params.map(p => p.name),
82+
...(bodyParam ? [bodyParam.name] : [])
83+
];
84+
85+
tests.push(`\n describe('${op.methodName}()', () => {`);
86+
87+
// Happy Path Test
88+
tests.push(` it('should return ${responseType} on success', () => {`);
89+
if (responseModel) {
90+
const mockResponse = this.mockDataGenerator.generate(responseModel);
91+
tests.push(` const mockResponse: ${responseModel} = ${mockResponse};`);
92+
} else {
93+
tests.push(` const mockResponse = null;`);
94+
}
95+
if (bodyParam?.model) {
96+
const mockBody = this.mockDataGenerator.generate(bodyParam.model);
97+
tests.push(` const ${bodyParam.name}: ${bodyParam.model} = ${mockBody};`);
98+
} else if (bodyParam) {
99+
tests.push(` const ${bodyParam.name} = 'test-body';`);
100+
}
101+
102+
params.forEach(p => tests.push(` const ${p.name} = ${p.value};`));
103+
104+
tests.push(` service.${op.methodName}(${allArgs.join(', ')}).subscribe(response => {`);
105+
tests.push(` expect(response).toEqual(mockResponse);`);
106+
tests.push(` });`);
107+
108+
const url = op.path.replace(/{(\w+)}/g, (_, paramName) => `\${${camelCase(paramName)}}`);
109+
tests.push(` const req = httpMock.expectOne(\`/api/v1${url}\`);`);
110+
tests.push(` expect(req.request.method).toBe('${op.method}');`);
111+
112+
if (bodyParam) {
113+
tests.push(` expect(req.request.body).toEqual(${bodyParam.name});`);
114+
}
115+
116+
tests.push(` req.flush(mockResponse);`);
117+
tests.push(` });`);
118+
119+
// Error Path Test
120+
tests.push(` it('should handle a 404 error', () => {`);
121+
if (bodyParam?.model) {
122+
const mockBody = this.mockDataGenerator.generate(bodyParam.model);
123+
tests.push(` const ${bodyParam.name}: ${bodyParam.model} = ${mockBody};`);
124+
} else if (bodyParam) {
125+
tests.push(` const ${bodyParam.name} = 'test-body';`);
126+
}
127+
params.forEach(p => tests.push(` const ${p.name} = ${p.value};`));
128+
129+
tests.push(` service.${op.methodName}(${allArgs.join(', ')}).subscribe({`);
130+
tests.push(` next: () => fail('should have failed with a 404 error'),`);
131+
tests.push(` error: (error) => {`);
132+
tests.push(` expect(error.status).toBe(404);`);
133+
tests.push(` }`);
134+
tests.push(` });`);
135+
136+
tests.push(` const req = httpMock.expectOne(\`/api/v1${url}\`);`);
137+
tests.push(` req.flush('Not Found', { status: 404, statusText: 'Not Found' });`);
138+
tests.push(` });`);
139+
140+
tests.push(` });`);
141+
}
142+
return tests;
143+
}
144+
145+
private addImports(sourceFile: SourceFile, serviceName: string, modelImports: string[]): void {
146+
sourceFile.addImportDeclarations([
147+
{ moduleSpecifier: '@angular/core/testing', namedImports: ['TestBed'] },
148+
{ moduleSpecifier: '@angular/common/http/testing', namedImports: ['HttpClientTestingModule', 'HttpTestingController'] },
149+
{ moduleSpecifier: `./${camelCase(serviceName.replace(/Service$/, ''))}.service`, namedImports: [serviceName] },
150+
{ moduleSpecifier: `../models`, namedImports: modelImports.length > 0 ? modelImports : [] },
151+
{ moduleSpecifier: '../tokens', namedImports: [getBasePathTokenName(this.config.clientName)] },
152+
]);
153+
}
154+
155+
private getMethodTypes(op: PathInfo): { responseModel?: string, responseType: string, bodyModel?: string } {
156+
const knownTypes = this.parser.schemas.map(s => s.name);
157+
const successResponseSchema = op.responses?.['200']?.content?.['application/json']?.schema;
158+
const responseType = successResponseSchema ? getTypeScriptType(successResponseSchema as any, this.config, knownTypes) : 'any';
159+
const responseModel = isDataTypeInterface(responseType.replace(/\[\]| \| null/g, '')) ? responseType.replace(/\[\]| \| null/g, '') : undefined;
160+
161+
const requestBodySchema = op.requestBody?.content?.['application/json']?.schema;
162+
const bodyType = requestBodySchema ? getTypeScriptType(requestBodySchema as any, this.config, knownTypes) : 'any';
163+
const bodyModel = isDataTypeInterface(bodyType.replace(/\[\]| \| null/g, '')) ? bodyType.replace(/\[\]| \| null/g, '') : undefined;
164+
165+
return { responseModel, responseType, bodyModel };
166+
}
167+
168+
private collectModelImports(operations: PathInfo[]): Set<string> {
169+
const modelImports = new Set<string>();
170+
for (const op of operations) {
171+
const { responseModel, bodyModel } = this.getMethodTypes(op);
172+
if (responseModel) modelImports.add(responseModel);
173+
if (bodyModel) modelImports.add(bodyModel);
174+
175+
(op.parameters ?? []).forEach(param => {
176+
const schemaObject = param.schema ? param.schema : param;
177+
const paramType = getTypeScriptType(schemaObject as any, this.config, this.parser.schemas.map(s => s.name)).replace(/\[\]| \| null/g, '');
178+
if (isDataTypeInterface(paramType)) {
179+
modelImports.add(paramType);
180+
}
181+
});
182+
}
183+
return modelImports;
184+
}
185+
}
Lines changed: 31 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,31 @@
1+
// tests/00-core/03-parser-coverage.spec.ts
2+
import { describe, it, expect, vi } from 'vitest';
3+
import { SwaggerParser } from '../../src/core/parser.js';
4+
import { parserCoverageSpec } from '../shared/specs.js';
5+
6+
describe('Core: SwaggerParser (Coverage)', () => {
7+
8+
it('getPolymorphicSchemaOptions should handle oneOf items that are not refs', () => {
9+
const parser = new SwaggerParser(parserCoverageSpec as any, { options: {} } as any);
10+
const schema = parser.getDefinition('Poly');
11+
const options = parser.getPolymorphicSchemaOptions(schema!);
12+
// The generator should gracefully ignore the non-$ref item
13+
expect(options.length).toBe(0);
14+
});
15+
16+
it('getPolymorphicSchemaOptions should handle refs to schemas without the discriminator property or enum', () => {
17+
const parser = new SwaggerParser(parserCoverageSpec as any, { options: {} } as any);
18+
const schema = parser.getDefinition('Poly');
19+
const options = parser.getPolymorphicSchemaOptions(schema!);
20+
// The generator should gracefully ignore Sub1 (no 'type' prop) and Sub2 (no 'enum' on 'type' prop)
21+
expect(options.length).toBe(0);
22+
});
23+
24+
it('loadContent should handle non-Error exceptions', async () => {
25+
global.fetch = vi.fn().mockImplementation(() => {
26+
// eslint-disable-next-line @typescript-eslint/no-throw-literal
27+
throw 'Network failure';
28+
});
29+
await expect(SwaggerParser.create('http://bad.url', {} as any)).rejects.toThrow('Failed to read content from "http://bad.url": Network failure');
30+
});
31+
});

0 commit comments

Comments
 (0)