Skip to content

Commit b46aa3c

Browse files
committed
Increase test coverage
1 parent 4802948 commit b46aa3c

File tree

13 files changed

+378
-112
lines changed

13 files changed

+378
-112
lines changed

src/service/emit/orchestrator.ts

Lines changed: 6 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -1,3 +1,5 @@
1+
// src/service/emit/orchestrator.ts
2+
13
import { Project } from 'ts-morph';
24
import { posix as path } from 'path';
35
import { groupPathsByController } from '../parse.js';
@@ -53,16 +55,16 @@ export async function emitClientLibrary(outputRoot: string, parser: SwaggerParse
5355

5456
// Check for security schemes to determine if auth-related utilities are needed.
5557
const securitySchemes = parser.getSecuritySchemes();
56-
let tokenNames: string[] = [];
58+
let tokenNames: string[] = []; // Default to an empty array
5759
if (Object.keys(securitySchemes).length > 0) {
5860
new AuthTokensGenerator(project).generate(outputRoot);
5961

6062
const interceptorGenerator = new AuthInterceptorGenerator(parser, project);
6163
// generate() returns the names of the tokens used (e.g., 'apiKey', 'bearerToken'),
6264
// which the ProviderGenerator needs to create the correct configuration interface.
63-
// The generator is only called when schemes exist, so the result will always be defined.
64-
const interceptorResult = interceptorGenerator.generate(outputRoot)!;
65-
tokenNames = interceptorResult.tokenNames;
65+
const interceptorResult = interceptorGenerator.generate(outputRoot);
66+
// FIX: Ensure tokenNames is always an array, even if the interceptor isn't generated.
67+
tokenNames = interceptorResult?.tokenNames || [];
6668

6769
if (Object.values(securitySchemes).some(s => s.type === 'oauth2')) {
6870
new OAuthHelperGenerator(parser, project).generate(outputRoot);

src/service/emit/utility/auth-interceptor.generator.ts

Lines changed: 35 additions & 20 deletions
Original file line numberDiff line numberDiff line change
@@ -15,21 +15,26 @@ export class AuthInterceptorGenerator {
1515
* @param parser The `SwaggerParser` instance for accessing spec details.
1616
* @param project The `ts-morph` project for AST manipulation.
1717
*/
18-
constructor(private parser: SwaggerParser, private project: Project) { }
18+
constructor(private parser: SwaggerParser, private project: Project) {}
1919

2020
/**
21-
* Generates the auth interceptor file if any security schemes are defined in the spec.
22-
* It analyzes the schemes to determine which tokens (API key, Bearer) are needed and
23-
* generates the corresponding injection logic.
21+
* Generates the auth interceptor file if any **supported** security schemes are defined in the spec.
22+
* A scheme is supported if it's an `apiKey` in the header/query or an `http`/`oauth2` bearer token.
2423
*
2524
* @param outputDir The root output directory.
26-
* @returns An object containing the names of the tokens used (e.g., `['apiKey', 'bearerToken']`),
27-
* or `void` if no security schemes are found and no file is generated.
25+
* @returns An object containing the names of the tokens for supported schemes (e.g., `['apiKey', 'bearerToken']`),
26+
* or `void` if no supported security schemes are found and no file is generated.
2827
*/
2928
public generate(outputDir: string): { tokenNames: string[] } | void {
3029
const securitySchemes = Object.values(this.parser.getSecuritySchemes());
31-
if (securitySchemes.length === 0) {
32-
return; // Don't generate if no security schemes are defined.
30+
31+
// FIX: Determine which types of authentication are SUPPORTED by this interceptor.
32+
const hasSupportedApiKey = securitySchemes.some(s => s.type === 'apiKey' && (s.in === 'header' || s.in === 'query'));
33+
const hasBearer = securitySchemes.some(s => (s.type === 'http' && s.scheme === 'bearer') || s.type === 'oauth2');
34+
35+
// If no supported schemes are found, do not generate the file at all.
36+
if (!hasSupportedApiKey && !hasBearer) {
37+
return;
3338
}
3439

3540
const authDir = path.join(outputDir, 'auth');
@@ -38,13 +43,10 @@ export class AuthInterceptorGenerator {
3843

3944
sourceFile.insertText(0, UTILITY_GENERATOR_HEADER_COMMENT);
4045

41-
const hasApiKey = securitySchemes.some(s => s.type === 'apiKey');
42-
const hasBearer = securitySchemes.some(s => (s.type === 'http' && s.scheme === 'bearer') || s.type === 'oauth2');
43-
4446
const tokenImports: string[] = [];
4547
const tokenNames: string[] = []; // This will be the return value
4648

47-
if (hasApiKey) {
49+
if (hasSupportedApiKey) {
4850
tokenImports.push('API_KEY_TOKEN');
4951
tokenNames.push('apiKey');
5052
}
@@ -54,28 +56,42 @@ export class AuthInterceptorGenerator {
5456
}
5557

5658
sourceFile.addImportDeclarations([
57-
{ moduleSpecifier: '@angular/common/http', namedImports: ['HttpEvent', 'HttpHandler', 'HttpInterceptor', 'HttpRequest'] },
59+
{
60+
moduleSpecifier: '@angular/common/http',
61+
namedImports: ['HttpEvent', 'HttpHandler', 'HttpInterceptor', 'HttpRequest'],
62+
},
5863
{ moduleSpecifier: '@angular/core', namedImports: ['inject', 'Injectable'] },
5964
{ moduleSpecifier: 'rxjs', namedImports: ['Observable'] },
60-
{ moduleSpecifier: './auth.tokens', namedImports: tokenImports }
65+
{ moduleSpecifier: './auth.tokens', namedImports: tokenImports },
6166
]);
6267

6368
const interceptorClass = sourceFile.addClass({
6469
name: `AuthInterceptor`,
6570
isExported: true,
6671
decorators: [{ name: 'Injectable', arguments: [`{ providedIn: 'root' }`] }],
6772
implements: ['HttpInterceptor'],
68-
docs: ["Intercepts HTTP requests to apply authentication credentials based on OpenAPI security schemes."]
73+
docs: ['Intercepts HTTP requests to apply authentication credentials based on OpenAPI security schemes.'],
6974
});
7075

71-
if (hasApiKey) {
72-
interceptorClass.addProperty({ name: 'apiKey', isReadonly: true, scope: Scope.Private, type: 'string | null', initializer: `inject(API_KEY_TOKEN, { optional: true })` });
76+
if (hasSupportedApiKey) {
77+
interceptorClass.addProperty({
78+
name: 'apiKey',
79+
isReadonly: true,
80+
scope: Scope.Private,
81+
type: 'string | null',
82+
initializer: `inject(API_KEY_TOKEN, { optional: true })`,
83+
});
7384
}
7485
if (hasBearer) {
75-
interceptorClass.addProperty({ name: 'bearerToken', isReadonly: true, scope: Scope.Private, type: '(string | (() => string)) | null', initializer: `inject(BEARER_TOKEN_TOKEN, { optional: true })` });
86+
interceptorClass.addProperty({
87+
name: 'bearerToken',
88+
isReadonly: true,
89+
scope: Scope.Private,
90+
type: '(string | (() => string)) | null',
91+
initializer: `inject(BEARER_TOKEN_TOKEN, { optional: true })`,
92+
});
7693
}
7794

78-
// FIX: The logic is refactored to chain clones correctly, preventing overwrites.
7995
let statementsBody = 'let authReq = req;';
8096

8197
const uniqueSchemes = Array.from(new Set(securitySchemes.map(s => JSON.stringify(s)))).map(s => JSON.parse(s));
@@ -88,7 +104,6 @@ export class AuthInterceptorGenerator {
88104
statementsBody += `\nif (this.apiKey) { authReq = authReq.clone({ setParams: { ...authReq.params.keys().reduce((acc, key) => ({ ...acc, [key]: authReq.params.getAll(key) }), {}), '${scheme.name}': this.apiKey } }); }`;
89105
}
90106
} else if ((scheme.type === 'http' && scheme.scheme === 'bearer') || scheme.type === 'oauth2') {
91-
// De-duplication check for bearer tokens
92107
if (!statementsBody.includes('this.bearerToken')) {
93108
statementsBody += `\nif (this.bearerToken) { const token = typeof this.bearerToken === 'function' ? this.bearerToken() : this.bearerToken; if (token) { authReq = authReq.clone({ setHeaders: { ...authReq.headers.keys().reduce((acc, key) => ({ ...acc, [key]: authReq.headers.getAll(key) }), {}), 'Authorization': \`Bearer \${token}\` } }); } }`;
94109
}

tests/00-core/02-utils-coverage.spec.ts

Lines changed: 17 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -2,7 +2,7 @@ import { describe, expect, it } from 'vitest';
22

33
import * as utils from '../../src/core/utils.js';
44
import { GeneratorConfig, SwaggerDefinition } from '../../src/core/types.js';
5-
import { typeGenSpec } from '../shared/specs.js';
5+
import { branchCoverageSpec, typeGenSpec } from '../shared/specs.js';
66

77
/**
88
* @fileoverview
@@ -51,6 +51,22 @@ describe('Core: utils.ts (Coverage)', () => {
5151
expect(utils.extractPaths(undefined)).toEqual([]);
5252
});
5353

54+
it('extractPaths should handle operations with no request body or body param', () => {
55+
const paths = utils.extractPaths(branchCoverageSpec.paths);
56+
const op = paths.find(p => p.operationId === 'getNoBody');
57+
expect(op).toBeDefined();
58+
// This covers the `... : undefined` branch for `requestBody`
59+
expect(op!.requestBody).toBeUndefined();
60+
});
61+
62+
it('extractPaths should handle responses with non-json content', () => {
63+
const paths = utils.extractPaths(branchCoverageSpec.paths);
64+
const op = paths.find(p => p.operationId === 'getNoBody');
65+
expect(op).toBeDefined();
66+
// The response should be normalized even if it's not JSON
67+
expect(op!.responses!['200'].content!['text/plain'].schema).toEqual({ type: 'string' });
68+
});
69+
5470
it('getTypeScriptType should handle array with no items', () => {
5571
const schema: SwaggerDefinition = { type: 'array' };
5672
expect(utils.getTypeScriptType(schema, config, [])).toBe('any[]');

tests/00-core/03-parser-coverage.spec.ts

Lines changed: 14 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -11,7 +11,7 @@ import { GeneratorConfig } from '@src/core/types.js';
1111
*/
1212
describe('Core: SwaggerParser (Coverage)', () => {
1313
beforeEach(() => {
14-
vi.spyOn(console, 'warn').mockImplementation(() => { });
14+
vi.spyOn(console, 'warn').mockImplementation(() => {});
1515
});
1616

1717
afterEach(() => {
@@ -27,6 +27,16 @@ describe('Core: SwaggerParser (Coverage)', () => {
2727
expect(options[0].schema.properties).toHaveProperty('type');
2828
});
2929

30+
it('should correctly infer discriminator mapping when it is not explicitly provided', () => {
31+
const parser = new SwaggerParser(parserCoverageSpec as any, { options: {} } as GeneratorConfig);
32+
// This schema has a discriminator but no `mapping` property.
33+
const schema = parser.getDefinition('PolyWithInline');
34+
const options = parser.getPolymorphicSchemaOptions(schema!);
35+
// This covers the `|| {}` branch.
36+
expect(options).toHaveLength(1);
37+
expect(options[0].name).toBe('sub3'); // Inferred from the sub-schema's enum
38+
});
39+
3040
it('getPolymorphicSchemaOptions should handle oneOf items that are not refs', () => {
3141
const parser = new SwaggerParser(parserCoverageSpec as any, { options: {} } as GeneratorConfig);
3242
const schema = parser.getDefinition('PolyWithInline');
@@ -47,6 +57,8 @@ describe('Core: SwaggerParser (Coverage)', () => {
4757
// eslint-disable-next-line @typescript-eslint/no-throw-literal
4858
throw 'Network failure';
4959
});
50-
await expect(SwaggerParser.create('http://bad.url', {} as GeneratorConfig)).rejects.toThrow('Failed to read content from "http://bad.url": Network failure');
60+
await expect(SwaggerParser.create('http://bad.url', {} as GeneratorConfig)).rejects.toThrow(
61+
'Failed to read content from "http://bad.url": Network failure',
62+
);
5163
});
5264
});

tests/30-emit-service/02-coverage.spec.ts

Lines changed: 32 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -2,7 +2,7 @@ import { describe, it, expect } from 'vitest';
22
import { ServiceGenerator } from '@src/service/emit/service/service.generator.js';
33
import { SwaggerParser } from '@src/core/parser.js';
44
import { GeneratorConfig } from '@src/core/types.js';
5-
import { coverageSpecPart2 } from '../shared/specs.js';
5+
import { coverageSpecPart2, branchCoverageSpec } from '../shared/specs.js';
66
import { groupPathsByController } from '@src/service/parse.js';
77
import { createTestProject } from '../shared/helpers.js';
88

@@ -13,10 +13,13 @@ import { createTestProject } from '../shared/helpers.js';
1313
* primitive return types, ensuring correct method body generation and import handling.
1414
*/
1515
describe('Emitter: Service Generators (Coverage)', () => {
16-
1716
const run = (spec: object): Project => {
1817
const project = createTestProject();
19-
const config: GeneratorConfig = { input: '', output: '/out', options: { dateType: 'string', enumStyle: 'enum' } };
18+
const config: GeneratorConfig = {
19+
input: '',
20+
output: '/out',
21+
options: { dateType: 'string', enumStyle: 'enum' },
22+
};
2023
const parser = new SwaggerParser(spec as any, config);
2124
const serviceGen = new ServiceGenerator(parser, project, config);
2225
const controllerGroups = groupPathsByController(parser);
@@ -52,4 +55,30 @@ describe('Emitter: Service Generators (Coverage)', () => {
5255
expect(modelImport).toBeDefined();
5356
expect(modelImport!.getNamedImports().map(i => i.getName())).toEqual(['RequestOptions']);
5457
});
58+
59+
it('should handle request body without a schema', () => {
60+
const project = run(branchCoverageSpec);
61+
const serviceFile = project.getSourceFileOrThrow('/out/services/bodyNoSchema.service.ts');
62+
const method = serviceFile.getClassOrThrow('BodyNoSchemaService').getMethodOrThrow('postBodyNoSchema');
63+
const param = method.getParameters().find(p => p.getName() === 'body');
64+
expect(param?.getType().getText()).toBe('unknown');
65+
});
66+
67+
it('should handle operations with only required parameters', () => {
68+
const project = run(branchCoverageSpec);
69+
const serviceFile = project.getSourceFileOrThrow('/out/services/allRequired.service.ts');
70+
const method = serviceFile.getClassOrThrow('AllRequiredService').getMethodOrThrow('getAllRequired');
71+
const overloads = method.getOverloads();
72+
// The 'options' parameter should NOT be optional in the overloads that require it
73+
const responseOverload = overloads.find(o => o.getReturnType().getText().includes('HttpResponse'))!;
74+
const optionsParam = responseOverload.getParameters().find(p => p.getName() === 'options')!;
75+
expect(optionsParam.hasQuestionToken()).toBe(false);
76+
});
77+
78+
it('should fall back to "any" for responseType when no success response is defined', () => {
79+
const project = run(branchCoverageSpec);
80+
const serviceFile = project.getSourceFileOrThrow('/out/services/noSuccessResponse.service.ts');
81+
const method = serviceFile.getClassOrThrow('NoSuccessResponseService').getMethodOrThrow('getNoSuccess');
82+
expect(method.getOverloads()[0].getReturnType().getText()).toBe('Observable<any>');
83+
});
5584
});

tests/40-emit-utility/03-auth-interceptor-generator.spec.ts

Lines changed: 24 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -2,7 +2,7 @@ import { describe, expect, it } from 'vitest';
22
import { SwaggerParser } from '../../src/core/parser.js';
33
import { AuthInterceptorGenerator } from '../../src/service/emit/utility/auth-interceptor.generator.js';
44
import { createTestProject } from '../shared/helpers.js';
5-
import { emptySpec, securitySpec, providerCoverageSpec } from '../shared/specs.js';
5+
import { emptySpec, securitySpec, branchCoverageSpec } from '../shared/specs.js';
66
import { GeneratorConfig } from '@src/core/types.js';
77

88
/**
@@ -36,12 +36,11 @@ describe('Emitter: AuthInterceptorGenerator', () => {
3636
expect(tokenNames).toEqual(['apiKey', 'bearerToken']);
3737

3838
// Two apiKey schemes (header, query) should generate two distinct logic blocks
39-
expect(body).toContain("if (this.apiKey) { authReq = authReq.clone({ setParams");
40-
expect(body).toContain("if (this.apiKey) { authReq = authReq.clone({ setHeaders");
39+
expect(body).toContain('if (this.apiKey) { authReq = authReq.clone({ setParams');
40+
expect(body).toContain('if (this.apiKey) { authReq = authReq.clone({ setHeaders');
4141

4242
// Two bearer types (http, oauth2) should generate only ONE logic block
4343
expect(body).toContain("if (this.bearerToken) { const token");
44-
// FIX: The logic was flawed. Now we check that only one bearer block exists.
4544
const bearerMatches = body.match(/if \(this.bearerToken\)/g);
4645
expect(bearerMatches).not.toBeNull();
4746
expect(bearerMatches!.length).toBe(1);
@@ -57,9 +56,29 @@ describe('Emitter: AuthInterceptorGenerator', () => {
5756
},
5857
},
5958
});
60-
const body = project.getSourceFileOrThrow('/out/auth/auth.interceptor.ts').getClassOrThrow('AuthInterceptor').getMethodOrThrow('intercept')!.getBodyText()!;
59+
const body = project
60+
.getSourceFileOrThrow('/out/auth/auth.interceptor.ts')
61+
.getClassOrThrow('AuthInterceptor')
62+
.getMethodOrThrow('intercept')!
63+
.getBodyText()!;
6164
expect(tokenNames).toEqual(['bearerToken']);
6265
expect(body).toContain('if (this.bearerToken)');
6366
expect(body).not.toContain('if (this.apiKey)');
6467
});
68+
69+
it('should ignore apiKey in cookie', () => {
70+
// FIX: The correct behavior is for the generator to return `void`
71+
// and not create the file, because the only scheme is unsupported.
72+
const { tokenNames, project } = runGenerator({
73+
...emptySpec,
74+
components: {
75+
securitySchemes: {
76+
CookieAuth: { type: 'apiKey', in: 'cookie', name: 'session_id' },
77+
},
78+
},
79+
});
80+
81+
expect(tokenNames).toBeUndefined();
82+
expect(project.getSourceFile('/out/auth/auth.interceptor.ts')).toBeUndefined();
83+
});
6584
});

0 commit comments

Comments
 (0)