diff --git a/src/generators/clean-arch.generator.ts b/src/generators/clean-arch.generator.ts index c6d1d17..aa71354 100644 --- a/src/generators/clean-arch.generator.ts +++ b/src/generators/clean-arch.generator.ts @@ -74,7 +74,8 @@ export function generateCleanArchitecture( mappers: 0, useCases: 0, providers: 0, - mocks: 0 + mocks: 0, + specs: 0 }; const schemas = @@ -210,6 +211,26 @@ export function generateCleanArchitecture( generatedCount, 'mocks' ); + + // Model spec + renderTemplate( + templatesDir, + 'model-entity.spec.mustache', + modelViewData, + path.join(outputDir, 'entities/models', `${toCamelCase(baseName)}.model.spec.ts`), + generatedCount, + 'specs' + ); + + // Mapper spec + renderTemplate( + templatesDir, + 'mapper.spec.mustache', + mapperViewData, + path.join(outputDir, 'data/mappers', `${toCamelCase(baseName)}.mapper.spec.ts`), + generatedCount, + 'specs' + ); }); // 2. Generate Use Cases and Repositories from Paths/Tags @@ -227,7 +248,8 @@ export function generateCleanArchitecture( paramName: p.name, dataType: mapSwaggerTypeToTs(p.schema?.type || ''), description: p.description || '', - required: p.required + required: p.required, + testValue: resolveTestParamValue(mapSwaggerTypeToTs(p.schema?.type || '')) })); if (op.requestBody) { @@ -241,7 +263,8 @@ export function generateCleanArchitecture( paramName: 'body', dataType: bodyType, description: op.requestBody.description || '', - required: true + required: true, + testValue: resolveTestParamValue(bodyType) }); } @@ -265,6 +288,7 @@ export function generateCleanArchitecture( summary: op.summary || '', notes: op.description || '', httpMethod: method.toLowerCase(), + uppercaseHttpMethod: method.toUpperCase(), path: pathKey, allParams: allParams.map((p, i: number) => ({ ...p, @@ -438,10 +462,30 @@ export function generateCleanArchitecture( generatedCount, 'mocks' ); + + // Repository impl spec + renderTemplate( + templatesDir, + 'api.repository.impl.spec.mustache', + apiViewData, + path.join(outputDir, 'data/repositories', `${toCamelCase(tag)}.repository.impl.spec.ts`), + generatedCount, + 'specs' + ); + + // Use-cases impl spec + renderTemplate( + templatesDir, + 'api.use-cases.impl.spec.mustache', + apiViewData, + path.join(outputDir, 'domain/use-cases', `${toCamelCase(tag)}.use-cases.impl.spec.ts`), + generatedCount, + 'specs' + ); }); logSuccess( - `${generatedCount.models} Models, ${generatedCount.repositories} Repos, ${generatedCount.useCases} Use Cases, ${generatedCount.mappers} Mappers, ${generatedCount.providers} Providers, ${generatedCount.mocks} Mocks generated` + `${generatedCount.models} Models, ${generatedCount.repositories} Repos, ${generatedCount.useCases} Use Cases, ${generatedCount.mappers} Mappers, ${generatedCount.providers} Providers, ${generatedCount.mocks} Mocks, ${generatedCount.specs} Specs generated` ); return generatedCount; } @@ -463,3 +507,18 @@ function renderTemplate( counter[key]++; } } + +/** Resolves a simple test value literal for a given TypeScript type. */ +function resolveTestParamValue(dataType: string): string { + switch (dataType) { + case 'string': + return "'test'"; + case 'number': + return '1'; + case 'boolean': + return 'true'; + default: + if (dataType.endsWith('[]')) return '[]'; + return '{} as any'; + } +} diff --git a/src/generators/report.generator.ts b/src/generators/report.generator.ts index f8386f7..c127b25 100644 --- a/src/generators/report.generator.ts +++ b/src/generators/report.generator.ts @@ -12,6 +12,15 @@ function countMockFiles(dir: string): number { } } +/** Counts files ending with `.spec.ts` in a directory (returns 0 if directory does not exist). */ +function countSpecFiles(dir: string): number { + try { + return fs.readdirSync(dir).filter((f) => f.endsWith('.spec.ts')).length; + } catch { + return 0; + } +} + /** Generates and persists the `generation-report.json` file with process statistics. */ export function generateReport(outputDir: string, analysis: SwaggerAnalysis): GenerationReport { logStep('Generating report...'); @@ -35,7 +44,12 @@ export function generateReport(outputDir: string, analysis: SwaggerAnalysis): Ge countMockFiles(path.join(outputDir, 'di/repositories')) + countMockFiles(path.join(outputDir, 'di/use-cases')) + countMockFiles(path.join(outputDir, 'domain/use-cases')) + - countMockFiles(path.join(outputDir, 'entities/models')) + countMockFiles(path.join(outputDir, 'entities/models')), + specs: + countSpecFiles(path.join(outputDir, 'entities/models')) + + countSpecFiles(path.join(outputDir, 'data/mappers')) + + countSpecFiles(path.join(outputDir, 'data/repositories')) + + countSpecFiles(path.join(outputDir, 'domain/use-cases')) } }; diff --git a/src/types/generation.types.ts b/src/types/generation.types.ts index 55f5368..22a3c87 100644 --- a/src/types/generation.types.ts +++ b/src/types/generation.types.ts @@ -8,6 +8,7 @@ export interface GeneratedCount { useCases: number; providers: number; mocks: number; + specs: number; } /** @@ -25,5 +26,6 @@ export interface GenerationReport { useCases: number; providers: number; mocks: number; + specs: number; }; } diff --git a/src/types/openapi.types.ts b/src/types/openapi.types.ts index 02498f2..1791429 100644 --- a/src/types/openapi.types.ts +++ b/src/types/openapi.types.ts @@ -96,6 +96,7 @@ export interface TagOperationParam { description: string; required: boolean; '-last': boolean; + testValue?: string; } /** @@ -107,6 +108,7 @@ export interface TagOperation { summary: string; notes: string; httpMethod: string; + uppercaseHttpMethod: string; path: string; allParams: TagOperationParam[]; hasQueryParams: boolean; diff --git a/templates/api.repository.impl.spec.mustache b/templates/api.repository.impl.spec.mustache new file mode 100644 index 0000000..7441c9d --- /dev/null +++ b/templates/api.repository.impl.spec.mustache @@ -0,0 +1,97 @@ +{{#apiInfo}} +{{#apis}} +{{#operations}} +import { HttpClientTestingModule, HttpTestingController } from '@angular/common/http/testing'; +import { TestBed } from '@angular/core/testing'; + +import { {{classname}}RepositoryImpl } from './{{classFilename}}.repository.impl'; +{{#returnImports}} +import { mock{{classname}}Dto } from '@/dtos/{{classFilename}}.dto.mock'; +import { mock{{classname}}Model } from '@/entities/models/{{classFilename}}.model.mock'; +{{/returnImports}} + +describe('{{classname}}RepositoryImpl', () => { + let repository: {{classname}}RepositoryImpl; + let httpMock: HttpTestingController; + + beforeEach(() => { + TestBed.configureTestingModule({ + imports: [HttpClientTestingModule], + providers: [{{classname}}RepositoryImpl] + }); + + repository = TestBed.inject({{classname}}RepositoryImpl); + httpMock = TestBed.inject(HttpTestingController); + }); + + afterEach(() => { + httpMock.verify(); + }); + + it('should be created', () => { + expect(repository).toBeTruthy(); + }); + +{{#operation}} + describe('{{nickname}}', () => { + it('should perform a {{uppercaseHttpMethod}} request', () => { + repository.{{nickname}}({{#allParams}}{{{testValue}}}{{^-last}}, {{/-last}}{{/allParams}}).subscribe(); + + const req = httpMock.expectOne((r) => r.method === '{{uppercaseHttpMethod}}'); + expect(req.request.method).toBe('{{uppercaseHttpMethod}}'); + {{#isListContainer}} + req.flush({ items: [mock{{returnBaseType}}Dto()] }); + {{/isListContainer}} + {{^isListContainer}} + {{#returnType}} + req.flush(mock{{returnBaseType}}Dto()); + {{/returnType}} + {{^returnType}} + req.flush(null); + {{/returnType}} + {{/isListContainer}} + }); + + {{#returnType}} + it('should map the response to the domain model', () => { + const dto = mock{{returnBaseType}}Dto(); + const expectedModel = mock{{returnBaseType}}Model(); + + {{#isListContainer}} + repository.{{nickname}}({{#allParams}}{{{testValue}}}{{^-last}}, {{/-last}}{{/allParams}}).subscribe((result) => { + expect(result).toBeTruthy(); + expect(result.length).toBeGreaterThan(0); + }); + + httpMock.expectOne((r) => r.method === '{{uppercaseHttpMethod}}').flush({ items: [dto] }); + {{/isListContainer}} + {{^isListContainer}} + repository.{{nickname}}({{#allParams}}{{{testValue}}}{{^-last}}, {{/-last}}{{/allParams}}).subscribe((result) => { + expect(result).toEqual(expectedModel); + }); + + httpMock.expectOne((r) => r.method === '{{uppercaseHttpMethod}}').flush(dto); + {{/isListContainer}} + }); + + {{/returnType}} + it('should propagate HTTP errors', (done) => { + repository.{{nickname}}({{#allParams}}{{{testValue}}}{{^-last}}, {{/-last}}{{/allParams}}).subscribe({ + error: (err) => { + expect(err.status).toBe(500); + done(); + } + }); + + httpMock + .expectOne((r) => r.method === '{{uppercaseHttpMethod}}') + .flush('Internal Server Error', { status: 500, statusText: 'Internal Server Error' }); + }); + }); + +{{/operation}} +}); + +{{/operations}} +{{/apis}} +{{/apiInfo}} diff --git a/templates/api.use-cases.impl.spec.mustache b/templates/api.use-cases.impl.spec.mustache new file mode 100644 index 0000000..2d80747 --- /dev/null +++ b/templates/api.use-cases.impl.spec.mustache @@ -0,0 +1,91 @@ +{{#apiInfo}} +{{#apis}} +{{#operations}} +import { TestBed } from '@angular/core/testing'; +import { of } from 'rxjs'; + +import { {{classname}}UseCasesImpl } from './{{classFilename}}.use-cases.impl'; + +import { {{constantName}}_REPOSITORY, {{classname}}Repository } from '@/domain/repositories/{{classFilename}}.repository.contract'; +{{#returnImports}} +import { mock{{classname}}Model } from '@/entities/models/{{classFilename}}.model.mock'; +{{/returnImports}} + +describe('{{classname}}UseCasesImpl', () => { + let useCase: {{classname}}UseCasesImpl; + let mockRepository: jasmine.SpyObj<{{classname}}Repository>; + + beforeEach(() => { + mockRepository = jasmine.createSpyObj('{{classname}}Repository', [{{#operation}}'{{nickname}}', {{/operation}}]); + + TestBed.configureTestingModule({ + providers: [ + {{classname}}UseCasesImpl, + { provide: {{constantName}}_REPOSITORY, useValue: mockRepository } + ] + }); + + useCase = TestBed.inject({{classname}}UseCasesImpl); + }); + + it('should be created', () => { + expect(useCase).toBeTruthy(); + }); + +{{#operation}} + describe('{{nickname}}', () => { + it('should delegate to the repository', () => { + {{#isListContainer}} + mockRepository.{{nickname}}.and.returnValue(of([mock{{returnBaseType}}Model()])); + {{/isListContainer}} + {{^isListContainer}} + {{#returnBaseType}} + mockRepository.{{nickname}}.and.returnValue(of(mock{{returnBaseType}}Model())); + {{/returnBaseType}} + {{^returnBaseType}} + mockRepository.{{nickname}}.and.returnValue(of(undefined)); + {{/returnBaseType}} + {{/isListContainer}} + + useCase.{{nickname}}({{#allParams}}{{{testValue}}}{{^-last}}, {{/-last}}{{/allParams}}); + + expect(mockRepository.{{nickname}}).toHaveBeenCalled(); + }); + + it('should return the observable from the repository', (done) => { + {{#isListContainer}} + const expected = [mock{{returnBaseType}}Model()]; + mockRepository.{{nickname}}.and.returnValue(of(expected)); + + useCase.{{nickname}}({{#allParams}}{{{testValue}}}{{^-last}}, {{/-last}}{{/allParams}}).subscribe((result) => { + expect(result).toEqual(expected); + done(); + }); + {{/isListContainer}} + {{^isListContainer}} + {{#returnBaseType}} + const expected = mock{{returnBaseType}}Model(); + mockRepository.{{nickname}}.and.returnValue(of(expected)); + + useCase.{{nickname}}({{#allParams}}{{{testValue}}}{{^-last}}, {{/-last}}{{/allParams}}).subscribe((result) => { + expect(result).toEqual(expected); + done(); + }); + {{/returnBaseType}} + {{^returnBaseType}} + mockRepository.{{nickname}}.and.returnValue(of(undefined)); + + useCase.{{nickname}}({{#allParams}}{{{testValue}}}{{^-last}}, {{/-last}}{{/allParams}}).subscribe({ + complete: () => done() + }); + {{/returnBaseType}} + {{/isListContainer}} + }); + }); + +{{/operation}} +}); + +{{/operations}} +{{/apis}} +{{/apiInfo}} diff --git a/templates/mapper.spec.mustache b/templates/mapper.spec.mustache new file mode 100644 index 0000000..bfe3d69 --- /dev/null +++ b/templates/mapper.spec.mustache @@ -0,0 +1,39 @@ +{{#models}} +{{#model}} +import { {{classVarName}}Mapper } from './{{classFilename}}.mapper'; + +import { mock{{classname}}Dto } from '@/dtos/{{classFilename}}.dto.mock'; +import { {{classname}} } from '@/entities/models/{{classFilename}}.model'; + +describe('{{classVarName}}Mapper', () => { +{{#vars}} + it('should map {{name}} from DTO to model', () => { + const dto = mock{{classname}}Dto(); + + const result = {{classVarName}}Mapper(dto); + + expect(result.{{name}}).toBe(dto.{{name}}); + }); + +{{/vars}} + it('should return an instance of {{classname}}', () => { + const dto = mock{{classname}}Dto(); + + const result = {{classVarName}}Mapper(dto); + + expect(result).toBeInstanceOf({{classname}}); + }); + + it('should map all fields correctly from a complete DTO', () => { + const dto = mock{{classname}}Dto(); + + const result = {{classVarName}}Mapper(dto); + +{{#vars}} + expect(result.{{name}}).toBe(dto.{{name}}); +{{/vars}} + }); +}); + +{{/model}} +{{/models}} diff --git a/templates/model-entity.spec.mustache b/templates/model-entity.spec.mustache new file mode 100644 index 0000000..2dc0860 --- /dev/null +++ b/templates/model-entity.spec.mustache @@ -0,0 +1,34 @@ +{{#models}} +{{#model}} +import { {{classname}} } from './{{classFilename}}.model'; +import { mock{{classname}}Model } from './{{classFilename}}.model.mock'; + +describe('{{classname}}', () => { + it('should create an instance', () => { + const model = new {{classname}}(); + + expect(model).toBeInstanceOf({{classname}}); + }); + +{{#vars}} + it('should allow setting {{name}}', () => { + const model = new {{classname}}(); + const expected = mock{{classname}}Model(); + model.{{name}} = expected.{{name}}; + + expect(model.{{name}}).toBe(expected.{{name}}); + }); + +{{/vars}} + it('should build a valid model from mock', () => { + const model = mock{{classname}}Model(); + + expect(model).toBeInstanceOf({{classname}}); +{{#vars}} + expect(model.{{name}}).toBeDefined(); +{{/vars}} + }); +}); + +{{/model}} +{{/models}}