Skip to content

Commit 2b8f6b0

Browse files
committed
Implement second version of service test generation; this time with more test coverage!
1 parent 57b8df0 commit 2b8f6b0

23 files changed

+458
-319
lines changed

src/core/utils.ts

Lines changed: 2 additions & 10 deletions
Original file line numberDiff line numberDiff line change
@@ -103,8 +103,6 @@ export function getTypeScriptType(schema: SwaggerDefinition | undefined | null,
103103
return 'any';
104104
}
105105

106-
// **FIX**: The `type: 'file'` from Swagger 2 is now handled explicitly in the service method generator.
107-
// This function can now safely return 'any', knowing it will be caught and refined later.
108106
if ((schema as any).type === 'file') {
109107
return 'any';
110108
}
@@ -113,7 +111,6 @@ export function getTypeScriptType(schema: SwaggerDefinition | undefined | null,
113111
const typeName = pascalCase(schema.$ref.split('/').pop() || '');
114112
return typeName && knownTypes.includes(typeName) ? typeName : 'any';
115113
}
116-
// ... (rest of getTypeScriptType remains the same) ...
117114
if (schema.allOf) {
118115
const parts = schema.allOf
119116
.map(s => getTypeScriptType(s, config, knownTypes))
@@ -242,7 +239,6 @@ export function extractPaths(swaggerPaths: { [p: string]: Path } | undefined): P
242239

243240
const allParams = Array.from(paramsMap.values());
244241

245-
// **FIX START**: We no longer filter out 'formData'. It's treated like any other parameter type now.
246242
const nonBodyParams = allParams.filter(p => p.in !== 'body');
247243
const bodyParam = allParams.find(p => p.in === 'body') as BodyParameter | undefined;
248244

@@ -255,14 +251,12 @@ export function extractPaths(swaggerPaths: { [p: string]: Path } | undefined): P
255251

256252
return {
257253
name,
258-
// Cast 'formData' to a valid 'in' type for our internal model. The service generator will handle the distinction.
259254
in: paramIn as "query" | "path" | "header" | "cookie",
260255
required,
261256
schema,
262257
description
263258
}
264259
});
265-
// **FIX END**
266260

267261
const requestBody = (operation as any).requestBody
268262
|| (bodyParam ? { content: { 'application/json': { schema: bodyParam.schema } } } : undefined);
@@ -286,12 +280,8 @@ export function extractPaths(swaggerPaths: { [p: string]: Path } | undefined): P
286280

287281
paths.push({
288282
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.
292283
...operation,
293284
method: method.toUpperCase(),
294-
// redundant properties will be overwritten correctly by the spread.
295285
parameters,
296286
requestBody: requestBody as RequestBody | undefined,
297287
responses: normalizedResponses,
@@ -308,6 +298,7 @@ export function extractPaths(swaggerPaths: { [p: string]: Path } | undefined): P
308298
* @param config The generator configuration.
309299
* @param knownTypes An array of known schema names for type resolution.
310300
* @returns A string representing the TypeScript type for the request body.
301+
* @internal
311302
*/
312303
export function getRequestBodyType(requestBody: RequestBody | undefined, config: GeneratorConfig, knownTypes: string[]): string {
313304
const schema = requestBody?.content?.['application/json']?.schema;
@@ -320,6 +311,7 @@ export function getRequestBodyType(requestBody: RequestBody | undefined, config:
320311
* @param config The generator configuration.
321312
* @param knownTypes An array of known schema names for type resolution.
322313
* @returns A string representing the TypeScript type for the response body.
314+
* @internal
323315
*/
324316
export function getResponseType(response: SwaggerResponse | undefined, config: GeneratorConfig, knownTypes: string[]): string {
325317
const schema = response?.content?.['application/json']?.schema;

src/service/emit/orchestrator.ts

Lines changed: 3 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -51,12 +51,15 @@ export async function emitClientLibrary(outputRoot: string, parser: SwaggerParse
5151
new DateTransformerGenerator(project).generate(outputRoot);
5252
}
5353

54+
// Check for security schemes to determine if auth-related utilities are needed.
5455
const securitySchemes = parser.getSecuritySchemes();
5556
let tokenNames: string[] = [];
5657
if (Object.keys(securitySchemes).length > 0) {
5758
new AuthTokensGenerator(project).generate(outputRoot);
5859

5960
const interceptorGenerator = new AuthInterceptorGenerator(parser, project);
61+
// generate() returns the names of the tokens used (e.g., 'apiKey', 'bearerToken'),
62+
// which the ProviderGenerator needs to create the correct configuration interface.
6063
// The generator is only called when schemes exist, so the result will always be defined.
6164
const interceptorResult = interceptorGenerator.generate(outputRoot)!;
6265
tokenNames = interceptorResult.tokenNames;
@@ -70,7 +73,6 @@ export async function emitClientLibrary(outputRoot: string, parser: SwaggerParse
7073
new ProviderGenerator(parser, project, tokenNames).generate(outputRoot);
7174

7275
console.log('✅ Utilities and providers generated.');
73-
// Stub for Service Test Generation
7476
if (config.options.generateServiceTests ?? true) {
7577
console.log('📝 Generating tests for services...');
7678
const testGenerator = new ServiceTestGenerator(parser, project, config);
@@ -85,7 +87,6 @@ export async function emitClientLibrary(outputRoot: string, parser: SwaggerParse
8587
await new AdminGenerator(parser, project, config).generate(outputRoot);
8688
if (config.options.generateAdminTests ?? true) {
8789
console.log('📝 Test generation for admin UI is stubbed.');
88-
// Future implementation: new AdminTestGenerator(parser, project, config).generate(outputRoot);
8990
}
9091
}
9192
}

src/service/emit/service/service-method.generator.ts

Lines changed: 5 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -1,8 +1,5 @@
1-
// src/service/emit/service/service-method.generator.ts
2-
31
import {
42
ClassDeclaration,
5-
MethodDeclarationStructure,
63
OptionalKind,
74
ParameterDeclarationStructure,
85
MethodDeclarationOverloadStructure,
@@ -11,6 +8,11 @@ import { GeneratorConfig, PathInfo, SwaggerDefinition } from '../../../core/type
118
import { SwaggerParser } from '../../../core/parser.js';
129
import { getTypeScriptType, camelCase, isDataTypeInterface } from '../../../core/utils.js';
1310

11+
/**
12+
* Generates individual methods within a generated Angular service class.
13+
* This class handles parameter mapping, response type resolution, method body construction,
14+
* and the creation of observable-based method overloads for different response types.
15+
*/
1416
export class ServiceMethodGenerator {
1517
constructor(
1618
private readonly config: GeneratorConfig,

src/service/emit/service/service.generator.ts

Lines changed: 1 addition & 13 deletions
Original file line numberDiff line numberDiff line change
@@ -8,7 +8,6 @@ import { camelCase, pascalCase, getBasePathTokenName, getClientContextTokenName,
88
import { SERVICE_GENERATOR_HEADER_COMMENT } from '../../../core/constants.js';
99
import { ServiceMethodGenerator } from './service-method.generator.js';
1010

11-
// ... (keep path_to_method_name_suffix function as is) ...
1211
function path_to_method_name_suffix(path: string): string {
1312
return path.split('/').filter(Boolean).map(segment => {
1413
if (segment.startsWith('{') && segment.endsWith('}')) {
@@ -35,7 +34,6 @@ export class ServiceGenerator {
3534
const knownTypes = this.parser.schemas.map(s => s.name);
3635
const modelImports = new Set<string>(['RequestOptions']);
3736

38-
// ... (keep model import discovery logic as is) ...
3937
for (const op of operations) {
4038
const successResponse = op.responses?.['200'] ?? op.responses?.['201'] ?? op.responses?.['default'];
4139
if (successResponse?.content?.['application/json']?.schema) {
@@ -65,7 +63,6 @@ export class ServiceGenerator {
6563
const serviceClass = this.addClass(sourceFile, className);
6664
this.addPropertiesAndHelpers(serviceClass);
6765

68-
const usedMethodNames = new Set<string>();
6966
operations.forEach(op => {
7067
let methodName: string;
7168
const customizer = this.config.options?.customizeMethodName;
@@ -77,21 +74,13 @@ export class ServiceGenerator {
7774
: `${op.method.toLowerCase()}${path_to_method_name_suffix(op.path)}`;
7875
}
7976

80-
let uniqueMethodName = methodName;
81-
let counter = 1;
82-
while (usedMethodNames.has(uniqueMethodName)) {
83-
uniqueMethodName = `${methodName}${++counter}`;
84-
}
85-
usedMethodNames.add(uniqueMethodName);
86-
op.methodName = uniqueMethodName;
87-
77+
// Method name de-duplication is now handled in `groupPathsByController`
8878
this.methodGenerator.addServiceMethod(serviceClass, op);
8979
});
9080
}
9181

9282
private addImports(sourceFile: SourceFile, modelImports: Set<string>): void {
9383
sourceFile.addImportDeclarations([
94-
// **FIX**: Added `HttpHeaders` to the imports for use in the method generator.
9584
{ moduleSpecifier: '@angular/core', namedImports: ['Injectable', 'inject'] },
9685
{ moduleSpecifier: '@angular/common/http', namedImports: ['HttpClient', 'HttpContext', 'HttpParams', 'HttpResponse', 'HttpEvent', 'HttpHeaders', 'HttpContextToken'] },
9786
{ moduleSpecifier: 'rxjs', namedImports: ['Observable'] },
@@ -114,7 +103,6 @@ export class ServiceGenerator {
114103
serviceClass.addProperty({ name: 'basePath', scope: Scope.Private, isReadonly: true, type: 'string', initializer: `inject(${getBasePathTokenName(this.config.clientName)})` });
115104
const clientContextTokenName = getClientContextTokenName(this.config.clientName);
116105

117-
// **FIX**: Correctly type the property without an inline import().
118106
serviceClass.addProperty({ name: "clientContextToken", type: `HttpContextToken<string>`, scope: Scope.Private, isReadonly: true, initializer: clientContextTokenName });
119107

120108
serviceClass.addMethod({

src/service/emit/test/mock-data.generator.ts

Lines changed: 15 additions & 11 deletions
Original file line numberDiff line numberDiff line change
@@ -1,35 +1,36 @@
11
import { SwaggerParser } from '../../../core/parser.js';
22
import { SwaggerDefinition } from '../../../core/types.js';
3-
import { pascalCase } from '../../../core/utils.js';
43

54
/**
65
* Generates mock data objects from OpenAPI schemas for use in tests.
6+
* It recursively traverses schema definitions to produce a representative
7+
* object with placeholder values, respecting formats and compositions (`allOf`).
78
*/
89
export class MockDataGenerator {
910
constructor(private parser: SwaggerParser) {}
1011

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-
*/
1612
public generate(schemaName: string): string {
1713
const schema = this.parser.schemas.find(s => s.name === schemaName)?.definition;
1814
if (!schema) {
1915
return '{}';
2016
}
2117
const mockObject = this.generateValue(schema, new Set());
22-
// Pretty-print the object string
2318
return JSON.stringify(mockObject, null, 2);
2419
}
2520

21+
/**
22+
* The core recursive value generation logic.
23+
* @param schema The schema definition to generate a value for.
24+
* @param visited A set to track visited schemas and prevent infinite recursion in cyclic definitions.
25+
* @returns A mock value (e.g., object, string, number) corresponding to the schema.
26+
* @private
27+
*/
2628
private generateValue(schema: SwaggerDefinition, visited: Set<SwaggerDefinition>): any {
2729
if (schema.example) {
2830
return schema.example;
2931
}
3032

3133
if (visited.has(schema)) {
32-
// Avoid infinite recursion
3334
return {};
3435
}
3536
visited.add(schema);
@@ -59,9 +60,9 @@ export class MockDataGenerator {
5960
return 'string-value';
6061
case 'number':
6162
case 'integer':
62-
return schema.minimum ?? 123;
63+
return schema.minimum ?? (schema.default ?? 123);
6364
case 'boolean':
64-
return true;
65+
return schema.default ?? true;
6566
case 'array':
6667
if (schema.items && !Array.isArray(schema.items)) {
6768
return [this.generateValue(schema.items as SwaggerDefinition, visited)];
@@ -71,7 +72,10 @@ export class MockDataGenerator {
7172
const obj: Record<string, any> = {};
7273
if (schema.properties) {
7374
for (const [propName, propSchema] of Object.entries(schema.properties)) {
74-
obj[propName] = this.generateValue(propSchema, visited);
75+
// Per OpenAPI spec, readOnly properties should not be sent in request bodies.
76+
if (!propSchema.readOnly) {
77+
obj[propName] = this.generateValue(propSchema, visited);
78+
}
7579
}
7680
}
7781
visited.delete(schema);

src/service/emit/test/service-test.generator.ts

Lines changed: 48 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,5 @@
11
import * as path from 'path';
2-
import { Project, ClassDeclaration, SourceFile } from 'ts-morph';
2+
import { Project, SourceFile } from 'ts-morph';
33
import { SwaggerParser } from '../../../core/parser.js';
44
import { GeneratorConfig, PathInfo } from '../../../core/types.js';
55
import {
@@ -11,6 +11,12 @@ import {
1111
} from '../../../core/utils.js';
1212
import { MockDataGenerator } from './mock-data.generator.js';
1313

14+
/**
15+
* Generates Angular service test files (`.spec.ts`) for each generated service.
16+
* This class creates a standard Angular testing setup with `TestBed`, mocks for `HttpClient`,
17+
* and generates `describe` and `it` blocks for each service method, covering both
18+
* success and error scenarios.
19+
*/
1420
export class ServiceTestGenerator {
1521
private mockDataGenerator: MockDataGenerator;
1622

@@ -22,6 +28,12 @@ export class ServiceTestGenerator {
2228
this.mockDataGenerator = new MockDataGenerator(parser);
2329
}
2430

31+
/**
32+
* Generates a complete test file for a single service (controller).
33+
* @param controllerName The PascalCase name of the controller (e.g., 'Users').
34+
* @param operations The list of operations belonging to this controller.
35+
* @param outputDir The directory where the `services` folder is located.
36+
*/
2537
public generateServiceTestFile(controllerName: string, operations: PathInfo[], outputDir: string): void {
2638
const serviceName = `${pascalCase(controllerName)}Service`;
2739
const testFileName = `${camelCase(controllerName)}.service.spec.ts`;
@@ -63,6 +75,12 @@ export class ServiceTestGenerator {
6375
sourceFile.formatText();
6476
}
6577

78+
/**
79+
* Generates the `describe` and `it` blocks for each method in a service.
80+
* @param operations The operations to generate tests for.
81+
* @returns An array of strings, each representing a line in the generated test file.
82+
* @private
83+
*/
6684
private generateMethodTests(operations: PathInfo[]): string[] {
6785
const tests: string[] = [];
6886
for (const op of operations) {
@@ -87,16 +105,19 @@ export class ServiceTestGenerator {
87105
// Happy Path Test
88106
tests.push(` it('should return ${responseType} on success', () => {`);
89107
if (responseModel) {
90-
const mockResponse = this.mockDataGenerator.generate(responseModel);
91-
tests.push(` const mockResponse: ${responseModel} = ${mockResponse};`);
108+
const singleMock = this.mockDataGenerator.generate(responseModel);
109+
// FIX: Check if the full response type is an array and wrap the mock data accordingly.
110+
const isArray = responseType.endsWith('[]');
111+
const mockResponse = isArray ? `[${singleMock}]` : singleMock;
112+
tests.push(` const mockResponse: ${responseType} = ${mockResponse};`);
92113
} else {
93114
tests.push(` const mockResponse = null;`);
94115
}
95116
if (bodyParam?.model) {
96117
const mockBody = this.mockDataGenerator.generate(bodyParam.model);
97118
tests.push(` const ${bodyParam.name}: ${bodyParam.model} = ${mockBody};`);
98119
} else if (bodyParam) {
99-
tests.push(` const ${bodyParam.name} = 'test-body';`);
120+
tests.push(` const ${bodyParam.name} = { data: 'test-body' };`);
100121
}
101122

102123
params.forEach(p => tests.push(` const ${p.name} = ${p.value};`));
@@ -122,7 +143,7 @@ export class ServiceTestGenerator {
122143
const mockBody = this.mockDataGenerator.generate(bodyParam.model);
123144
tests.push(` const ${bodyParam.name}: ${bodyParam.model} = ${mockBody};`);
124145
} else if (bodyParam) {
125-
tests.push(` const ${bodyParam.name} = 'test-body';`);
146+
tests.push(` const ${bodyParam.name} = { data: 'test-body' };`);
126147
}
127148
params.forEach(p => tests.push(` const ${p.name} = ${p.value};`));
128149

@@ -142,6 +163,12 @@ export class ServiceTestGenerator {
142163
return tests;
143164
}
144165

166+
/**
167+
* Adds all necessary import statements to the test file.
168+
* @param sourceFile The ts-morph SourceFile object.
169+
* @param serviceName The name of the service class being tested.
170+
* @param modelImports A list of model names that need to be imported.
171+
*/
145172
private addImports(sourceFile: SourceFile, serviceName: string, modelImports: string[]): void {
146173
sourceFile.addImportDeclarations([
147174
{ moduleSpecifier: '@angular/core/testing', namedImports: ['TestBed'] },
@@ -152,19 +179,33 @@ export class ServiceTestGenerator {
152179
]);
153180
}
154181

182+
/**
183+
* Extracts the TypeScript type names for a method's response and request body.
184+
* @param op The operation to analyze.
185+
* @returns An object containing the model names for the response and body, if they are complex types.
186+
* @private
187+
*/
155188
private getMethodTypes(op: PathInfo): { responseModel?: string, responseType: string, bodyModel?: string } {
156189
const knownTypes = this.parser.schemas.map(s => s.name);
157190
const successResponseSchema = op.responses?.['200']?.content?.['application/json']?.schema;
158191
const responseType = successResponseSchema ? getTypeScriptType(successResponseSchema as any, this.config, knownTypes) : 'any';
159-
const responseModel = isDataTypeInterface(responseType.replace(/\[\]| \| null/g, '')) ? responseType.replace(/\[\]| \| null/g, '') : undefined;
192+
const responseModelType = responseType.replace(/\[\]| \| null/g, '');
193+
const responseModel = isDataTypeInterface(responseModelType) ? responseModelType : undefined;
160194

161195
const requestBodySchema = op.requestBody?.content?.['application/json']?.schema;
162196
const bodyType = requestBodySchema ? getTypeScriptType(requestBodySchema as any, this.config, knownTypes) : 'any';
163-
const bodyModel = isDataTypeInterface(bodyType.replace(/\[\]| \| null/g, '')) ? bodyType.replace(/\[\]| \| null/g, '') : undefined;
197+
const bodyModelType = bodyType.replace(/\[\]| \| null/g, '');
198+
const bodyModel = isDataTypeInterface(bodyModelType) ? bodyModelType : undefined;
164199

165200
return { responseModel, responseType, bodyModel };
166201
}
167202

203+
/**
204+
* Scans all operations for a service to collect a unique set of model names that need to be imported.
205+
* @param operations The list of operations for the service.
206+
* @returns A Set containing the names of all required model imports.
207+
* @private
208+
*/
168209
private collectModelImports(operations: PathInfo[]): Set<string> {
169210
const modelImports = new Set<string>();
170211
for (const op of operations) {

0 commit comments

Comments
 (0)