From 49aa7012c4e376d51d25fc20a67459680b967cc3 Mon Sep 17 00:00:00 2001 From: Josephat-S Date: Sat, 23 May 2026 09:02:12 +0200 Subject: [PATCH] Implemented authentication scaffold --- src/commands/__tests__/generate.spec.ts | 257 +++++++++++++ src/commands/generate.ts | 161 +++++++- src/commands/new.ts | 1 + src/constants/enums.ts | 33 ++ src/index.ts | 9 +- .../__tests__/auth-generator.service.spec.ts | 229 ++++++++++++ .../__tests__/generate.service.spec.ts | 348 ++++++++++++++++++ src/services/auth-generator.service.ts | 216 +++++++++++ src/services/file-generator.service.ts | 20 +- src/services/generate.service.ts | 287 +++++++++++++++ src/services/prompts.service.ts | 34 +- .../auth/email-controller.template.ts | 41 +++ src/templates/auth/email-spec.template.ts | 50 +++ src/templates/auth/email.template.ts | 50 +++ src/templates/auth/index.ts | 35 ++ src/templates/auth/jwt.template.ts | 298 +++++++++++++++ src/templates/auth/oauth.template.ts | 181 +++++++++ src/templates/auth/rbac.template.ts | 107 ++++++ src/templates/generate/controller.template.ts | 38 ++ src/templates/generate/guard.template.ts | 45 +++ src/templates/generate/index.ts | 6 + .../generate/interceptor.template.ts | 43 +++ src/templates/generate/module.template.ts | 36 ++ src/templates/generate/pipe.template.ts | 43 +++ src/templates/generate/service.template.ts | 38 ++ src/types/project.types.ts | 27 +- 26 files changed, 2624 insertions(+), 9 deletions(-) create mode 100644 src/services/__tests__/auth-generator.service.spec.ts create mode 100644 src/services/__tests__/generate.service.spec.ts create mode 100644 src/services/auth-generator.service.ts create mode 100644 src/services/generate.service.ts create mode 100644 src/templates/auth/email-controller.template.ts create mode 100644 src/templates/auth/email-spec.template.ts create mode 100644 src/templates/auth/email.template.ts create mode 100644 src/templates/auth/index.ts create mode 100644 src/templates/auth/jwt.template.ts create mode 100644 src/templates/auth/oauth.template.ts create mode 100644 src/templates/auth/rbac.template.ts create mode 100644 src/templates/generate/controller.template.ts create mode 100644 src/templates/generate/guard.template.ts create mode 100644 src/templates/generate/index.ts create mode 100644 src/templates/generate/interceptor.template.ts create mode 100644 src/templates/generate/module.template.ts create mode 100644 src/templates/generate/pipe.template.ts create mode 100644 src/templates/generate/service.template.ts diff --git a/src/commands/__tests__/generate.spec.ts b/src/commands/__tests__/generate.spec.ts index b3b5322..1543ab7 100644 --- a/src/commands/__tests__/generate.spec.ts +++ b/src/commands/__tests__/generate.spec.ts @@ -1,7 +1,264 @@ import { generateCommand } from '../generate'; +import { GenerateService } from '../../services/generate.service'; +import { AuthGeneratorService } from '../../services/auth-generator.service'; +import { Schematic, AuthFeature } from '../../constants/enums'; + +jest.mock('chalk', () => ({ + red: jest.fn((str: string) => str), + green: jest.fn((str: string) => str), + cyan: jest.fn((str: string) => str), + white: jest.fn((str: string) => str), + yellow: jest.fn((str: string) => str), + gray: jest.fn((str: string) => str), +})); + +jest.mock('../../services/generate.service'); +jest.mock('../../services/auth-generator.service'); +jest.mock('inquirer'); + +import inquirer from 'inquirer'; describe('generateCommand', () => { + let consoleSpy: jest.SpyInstance; + let processExitSpy: jest.SpyInstance; + let mockPrompt: jest.Mock; + + beforeEach(() => { + consoleSpy = jest.spyOn(console, 'log').mockImplementation(); + jest.spyOn(console, 'error').mockImplementation(); + processExitSpy = jest + .spyOn(process, 'exit') + .mockImplementation(() => undefined as never); + mockPrompt = inquirer.prompt as unknown as jest.Mock; + jest.clearAllMocks(); + }); + + afterEach(() => { + jest.restoreAllMocks(); + }); + it('should be defined', () => { expect(generateCommand).toBeDefined(); }); + + it('should exit with error for unknown schematic', async () => { + await generateCommand('unknown', 'test', { + skipSpec: false, + flat: false, + dryRun: false, + }); + + expect(processExitSpy).toHaveBeenCalledWith(1); + }); + + it('should resolve "mo" alias to module schematic', async () => { + (GenerateService.generate as jest.Mock).mockReturnValue([ + { filePath: '/src/test/test.module.ts', content: '' }, + ]); + + await generateCommand('mo', 'test', { + skipSpec: false, + flat: false, + dryRun: false, + }); + + expect(GenerateService.generate).toHaveBeenCalledWith( + expect.objectContaining({ schematic: Schematic.MODULE }), + ); + }); + + it('should resolve "co" alias to controller schematic', async () => { + (GenerateService.generate as jest.Mock).mockReturnValue([ + { filePath: '/src/test/test.controller.ts', content: '' }, + ]); + + await generateCommand('co', 'users', { + skipSpec: false, + flat: false, + dryRun: false, + }); + + expect(GenerateService.generate).toHaveBeenCalledWith( + expect.objectContaining({ schematic: Schematic.CONTROLLER }), + ); + }); + + it('should resolve "s" alias to service schematic', async () => { + (GenerateService.generate as jest.Mock).mockReturnValue([ + { filePath: '/src/test/test.service.ts', content: '' }, + ]); + + await generateCommand('s', 'users', { + skipSpec: false, + flat: false, + dryRun: false, + }); + + expect(GenerateService.generate).toHaveBeenCalledWith( + expect.objectContaining({ schematic: Schematic.SERVICE }), + ); + }); + + it('should resolve "gu" alias to guard schematic', async () => { + (GenerateService.generate as jest.Mock).mockReturnValue([ + { filePath: '/src/common/guards/auth.guard.ts', content: '' }, + ]); + + await generateCommand('gu', 'auth', { + skipSpec: false, + flat: false, + dryRun: false, + }); + + expect(GenerateService.generate).toHaveBeenCalledWith( + expect.objectContaining({ schematic: Schematic.GUARD }), + ); + }); + + it('should resolve "i" alias to interceptor schematic', async () => { + (GenerateService.generate as jest.Mock).mockReturnValue([ + { filePath: '/src/common/interceptors/logging.interceptor.ts', content: '' }, + ]); + + await generateCommand('i', 'logging', { + skipSpec: false, + flat: false, + dryRun: false, + }); + + expect(GenerateService.generate).toHaveBeenCalledWith( + expect.objectContaining({ schematic: Schematic.INTERCEPTOR }), + ); + }); + + it('should resolve "p" alias to pipe schematic', async () => { + (GenerateService.generate as jest.Mock).mockReturnValue([ + { filePath: '/src/common/pipes/validation.pipe.ts', content: '' }, + ]); + + await generateCommand('p', 'validation', { + skipSpec: false, + flat: false, + dryRun: false, + }); + + expect(GenerateService.generate).toHaveBeenCalledWith( + expect.objectContaining({ schematic: Schematic.PIPE }), + ); + }); + + it('should pass skipSpec option correctly', async () => { + (GenerateService.generate as jest.Mock).mockReturnValue([ + { filePath: '/src/test/test.service.ts', content: '' }, + ]); + + await generateCommand('service', 'test', { + skipSpec: true, + flat: false, + dryRun: false, + }); + + expect(GenerateService.generate).toHaveBeenCalledWith( + expect.objectContaining({ + options: expect.objectContaining({ skipSpec: true }), + }), + ); + }); + + it('should pass flat option correctly', async () => { + (GenerateService.generate as jest.Mock).mockReturnValue([ + { filePath: '/src/test.service.ts', content: '' }, + ]); + + await generateCommand('service', 'test', { + skipSpec: false, + flat: true, + dryRun: false, + }); + + expect(GenerateService.generate).toHaveBeenCalledWith( + expect.objectContaining({ + options: expect.objectContaining({ flat: true }), + }), + ); + }); + + it('should handle dry-run mode', async () => { + (GenerateService.generate as jest.Mock).mockReturnValue([ + { filePath: '/src/test/test.service.ts', content: '' }, + { filePath: '/src/test/test.service.spec.ts', content: '' }, + ]); + + await generateCommand('service', 'test', { + skipSpec: false, + flat: false, + dryRun: true, + }); + + expect(GenerateService.generate).toHaveBeenCalledWith( + expect.objectContaining({ + options: expect.objectContaining({ dryRun: true }), + }), + ); + }); + + it('should handle errors gracefully', async () => { + (GenerateService.generate as jest.Mock).mockImplementation(() => { + throw new Error('File system error'); + }); + + await generateCommand('service', 'test', { + skipSpec: false, + flat: false, + dryRun: false, + }); + + expect(processExitSpy).toHaveBeenCalledWith(1); + }); + + describe('auth schematic', () => { + it('should prompt for auth features when schematic is "auth"', async () => { + mockPrompt.mockResolvedValue({ + authFeatures: [AuthFeature.JWT], + }); + (AuthGeneratorService.generate as jest.Mock).mockReturnValue([ + { filePath: '/src/auth/auth.module.ts', content: '' }, + ]); + + await generateCommand('auth', '', { + skipSpec: false, + flat: false, + dryRun: false, + }); + + expect(mockPrompt).toHaveBeenCalled(); + expect(AuthGeneratorService.generate).toHaveBeenCalledWith( + expect.objectContaining({ + features: [AuthFeature.JWT], + }), + ); + }); + + it('should handle auth dry-run mode', async () => { + mockPrompt.mockResolvedValue({ + authFeatures: [AuthFeature.JWT, AuthFeature.RBAC], + }); + (AuthGeneratorService.generate as jest.Mock).mockReturnValue([ + { filePath: '/src/auth/auth.module.ts', content: '' }, + { filePath: '/src/auth/guards/roles.guard.ts', content: '' }, + ]); + + await generateCommand('auth', '', { + skipSpec: false, + flat: false, + dryRun: true, + }); + + expect(AuthGeneratorService.generate).toHaveBeenCalledWith( + expect.objectContaining({ + options: expect.objectContaining({ dryRun: true }), + }), + ); + }); + }); }); diff --git a/src/commands/generate.ts b/src/commands/generate.ts index d618dcf..b668dc0 100644 --- a/src/commands/generate.ts +++ b/src/commands/generate.ts @@ -1,7 +1,160 @@ import chalk from 'chalk'; +import inquirer from 'inquirer'; +import { Schematic, SCHEMATIC_ALIASES, AuthFeature } from '../constants/enums'; +import { + GenerateCommandOptions, + GenerateConfig, + AuthGenerateConfig, +} from '../types/project.types'; +import { GenerateService } from '../services/generate.service'; +import { AuthGeneratorService } from '../services/auth-generator.service'; -export async function generateCommand(schematic: string, name: string) { - console.log(chalk.yellow(`āš ļø Generate command coming soon!`)); - console.log(chalk.cyan(`Will generate: ${schematic} named ${name}`)); - console.log(chalk.gray('This feature is under development...')); +export async function generateCommand( + schematic: string, + name: string, + options: GenerateCommandOptions, +) { + const resolvedSchematic = SCHEMATIC_ALIASES[schematic.toLowerCase()]; + + if (!resolvedSchematic) { + console.log(chalk.red(`\nāŒ Unknown schematic: "${schematic}"`)); + printAvailableSchematics(); + process.exit(1); + } + + // Handle auth schematic separately + if (resolvedSchematic === Schematic.AUTH) { + await handleAuthGeneration(options); + return; + } + + const config: GenerateConfig = { + schematic: resolvedSchematic, + name: name.toLowerCase(), + options: { + skipSpec: options.skipSpec || false, + flat: options.flat || false, + dryRun: options.dryRun || false, + }, + }; + + try { + const files = GenerateService.generate(config); + printResult(resolvedSchematic, name, files, options.dryRun); + } catch (error) { + console.error(chalk.red(`\nāŒ Failed to generate ${resolvedSchematic}`)); + console.error(chalk.red(`Error: ${(error as Error).message}`)); + process.exit(1); + } +} + +async function handleAuthGeneration( + options: GenerateCommandOptions, +): Promise { + const { authFeatures } = await inquirer.prompt([ + { + type: 'checkbox', + name: 'authFeatures', + message: 'Which authentication features would you like to include?', + choices: [ + { name: 'JWT Authentication', value: AuthFeature.JWT }, + { name: 'OAuth (Google)', value: AuthFeature.OAUTH }, + { name: 'Role-Based Access Control (RBAC)', value: AuthFeature.RBAC }, + { + name: 'Email Verification & Password Reset', + value: AuthFeature.EMAIL_VERIFICATION, + }, + ], + validate: (input: AuthFeature[]) => + input.length > 0 || 'Please select at least one feature', + }, + ]); + + const config: AuthGenerateConfig = { + features: authFeatures, + options: { + skipSpec: options.skipSpec || false, + flat: options.flat || false, + dryRun: options.dryRun || false, + }, + }; + + try { + const files = AuthGeneratorService.generate(config); + + if (options.dryRun) { + console.log(chalk.yellow('\nšŸƒ Dry run - no files written\n')); + console.log(chalk.cyan('Files that would be created:')); + } else { + console.log(chalk.green('\nāœ… Authentication scaffolded successfully!\n')); + console.log(chalk.cyan('Created files:')); + } + + for (const file of files) { + console.log(chalk.white(` CREATE ${file.filePath}`)); + } + + if (!options.dryRun) { + printAuthNextSteps(authFeatures); + } + } catch (error) { + console.error(chalk.red('\nāŒ Failed to generate authentication')); + console.error(chalk.red(`Error: ${(error as Error).message}`)); + process.exit(1); + } +} + +function printResult( + schematic: string, + name: string, + files: { filePath: string }[], + dryRun: boolean, +): void { + if (dryRun) { + console.log(chalk.yellow('\nšŸƒ Dry run - no files written\n')); + console.log(chalk.cyan('Files that would be created:')); + } else { + console.log(chalk.green(`\nāœ… Generated ${schematic}: ${name}\n`)); + } + + for (const file of files) { + console.log(chalk.white(` CREATE ${file.filePath}`)); + } +} + +function printAvailableSchematics(): void { + console.log(chalk.cyan('\nAvailable schematics:')); + console.log(chalk.white(' module (mo) - Generate a module')); + console.log(chalk.white(' controller (co) - Generate a controller')); + console.log(chalk.white(' service (s) - Generate a service')); + console.log(chalk.white(' guard (gu) - Generate a guard')); + console.log(chalk.white(' interceptor (i) - Generate an interceptor')); + console.log(chalk.white(' pipe (p) - Generate a pipe')); + console.log(chalk.white(' auth - Generate authentication')); +} + +function printAuthNextSteps(features: AuthFeature[]): void { + console.log(chalk.cyan('\nšŸ“‹ Next steps:')); + console.log(chalk.white(' 1. Install required dependencies:')); + + const deps: string[] = ['@nestjs/passport', '@nestjs/jwt', 'passport']; + + if (features.includes(AuthFeature.JWT)) { + deps.push('passport-jwt', 'passport-local'); + deps.push('@types/passport-jwt', '@types/passport-local'); + } + if (features.includes(AuthFeature.OAUTH)) { + deps.push('passport-google-oauth20', '@types/passport-google-oauth20'); + } + + console.log(chalk.gray(` npm install ${deps.join(' ')}`)); + console.log(chalk.white(' 2. Import AuthModule in your AppModule')); + console.log(chalk.white(' 3. Configure JWT_SECRET in your .env file')); + + if (features.includes(AuthFeature.OAUTH)) { + console.log( + chalk.white(' 4. Set GOOGLE_CLIENT_ID, GOOGLE_CLIENT_SECRET,'), + ); + console.log(chalk.white(' and GOOGLE_CALLBACK_URL in your .env')); + } } diff --git a/src/commands/new.ts b/src/commands/new.ts index 430ad6a..c3ea99c 100644 --- a/src/commands/new.ts +++ b/src/commands/new.ts @@ -48,6 +48,7 @@ export async function newCommand( FileGeneratorService.generateConfigFiles(config); FileGeneratorService.generateDockerFiles(config); FileGeneratorService.generateGitHubActionsFiles(config); + FileGeneratorService.generateAuthFiles(config); FileGeneratorService.generateReadme(config); spinner.succeed('Project structure created!'); diff --git a/src/constants/enums.ts b/src/constants/enums.ts index 2599771..1892cdb 100644 --- a/src/constants/enums.ts +++ b/src/constants/enums.ts @@ -20,3 +20,36 @@ export enum Environment { TESTING = 'testing', PRODUCTION = 'production', } + +export enum Schematic { + MODULE = 'module', + CONTROLLER = 'controller', + SERVICE = 'service', + GUARD = 'guard', + INTERCEPTOR = 'interceptor', + PIPE = 'pipe', + AUTH = 'auth', +} + +export const SCHEMATIC_ALIASES: Record = { + module: Schematic.MODULE, + mo: Schematic.MODULE, + controller: Schematic.CONTROLLER, + co: Schematic.CONTROLLER, + service: Schematic.SERVICE, + s: Schematic.SERVICE, + guard: Schematic.GUARD, + gu: Schematic.GUARD, + interceptor: Schematic.INTERCEPTOR, + i: Schematic.INTERCEPTOR, + pipe: Schematic.PIPE, + p: Schematic.PIPE, + auth: Schematic.AUTH, +}; + +export enum AuthFeature { + JWT = 'jwt', + OAUTH = 'oauth', + RBAC = 'rbac', + EMAIL_VERIFICATION = 'email-verification', +} diff --git a/src/index.ts b/src/index.ts index 0de6e7a..8537e22 100644 --- a/src/index.ts +++ b/src/index.ts @@ -32,9 +32,14 @@ program // Add the 'generate' command (g for short) program - .command('generate ') + .command('generate [name]') .alias('g') - .description('Generate a new component (module, controller, service)') + .description( + 'Generate a NestJS component (module|mo, controller|co, service|s, guard|gu, interceptor|i, pipe|p, auth)', + ) + .option('--skip-spec', 'Do not generate spec files', false) + .option('--flat', 'Do not create a subdirectory', false) + .option('--dry-run', 'Preview files without writing', false) .action(generateCommand); program.parse(process.argv); diff --git a/src/services/__tests__/auth-generator.service.spec.ts b/src/services/__tests__/auth-generator.service.spec.ts new file mode 100644 index 0000000..b788cfc --- /dev/null +++ b/src/services/__tests__/auth-generator.service.spec.ts @@ -0,0 +1,229 @@ +import fs from 'fs-extra'; +import path from 'path'; +import { AuthGeneratorService } from '../auth-generator.service'; +import { AuthFeature } from '../../constants/enums'; +import { AuthGenerateConfig } from '../../types/project.types'; + +jest.mock('fs-extra'); + +describe('AuthGeneratorService', () => { + const mockCwd = '/project'; + + beforeEach(() => { + jest.clearAllMocks(); + jest.spyOn(process, 'cwd').mockReturnValue(mockCwd); + (fs.existsSync as jest.Mock).mockImplementation((p: string) => { + if (p === path.join(mockCwd, 'src')) return true; + return false; + }); + }); + + afterEach(() => { + jest.restoreAllMocks(); + }); + + describe('resolveAuthDirectory', () => { + it('should resolve to src/auth when src exists', () => { + const result = AuthGeneratorService.resolveAuthDirectory(); + expect(result).toBe(path.join(mockCwd, 'src', 'auth')); + }); + + it('should resolve to cwd/auth when src does not exist', () => { + (fs.existsSync as jest.Mock).mockReturnValue(false); + const result = AuthGeneratorService.resolveAuthDirectory(); + expect(result).toBe(path.join(mockCwd, 'auth')); + }); + }); + + describe('getJwtFiles', () => { + it('should return JWT auth files with specs', () => { + const config: AuthGenerateConfig = { + features: [AuthFeature.JWT], + options: { skipSpec: false, flat: false, dryRun: false }, + }; + + const files = AuthGeneratorService.getJwtFiles(config); + + const filePaths = files.map((f) => f.filePath); + expect(filePaths).toContainEqual( + expect.stringContaining('auth.module.ts'), + ); + expect(filePaths).toContainEqual( + expect.stringContaining('auth.service.ts'), + ); + expect(filePaths).toContainEqual( + expect.stringContaining('auth.controller.ts'), + ); + expect(filePaths).toContainEqual( + expect.stringContaining('jwt.strategy.ts'), + ); + expect(filePaths).toContainEqual( + expect.stringContaining('local.strategy.ts'), + ); + expect(filePaths).toContainEqual( + expect.stringContaining('jwt-auth.guard.ts'), + ); + expect(filePaths).toContainEqual( + expect.stringContaining('local-auth.guard.ts'), + ); + expect(filePaths).toContainEqual( + expect.stringContaining('auth.service.spec.ts'), + ); + expect(filePaths).toContainEqual( + expect.stringContaining('auth.controller.spec.ts'), + ); + }); + + it('should skip spec files when skipSpec is true', () => { + const config: AuthGenerateConfig = { + features: [AuthFeature.JWT], + options: { skipSpec: true, flat: false, dryRun: false }, + }; + + const files = AuthGeneratorService.getJwtFiles(config); + const specFiles = files.filter((f) => f.filePath.includes('.spec.')); + + expect(specFiles).toHaveLength(0); + }); + }); + + describe('getOAuthFiles', () => { + it('should return OAuth files', () => { + const config: AuthGenerateConfig = { + features: [AuthFeature.OAUTH], + options: { skipSpec: false, flat: false, dryRun: false }, + }; + + const files = AuthGeneratorService.getOAuthFiles(config); + const filePaths = files.map((f) => f.filePath); + + expect(filePaths).toContainEqual( + expect.stringContaining('oauth.module.ts'), + ); + expect(filePaths).toContainEqual( + expect.stringContaining('oauth.service.ts'), + ); + expect(filePaths).toContainEqual( + expect.stringContaining('oauth.controller.ts'), + ); + expect(filePaths).toContainEqual( + expect.stringContaining('google.strategy.ts'), + ); + expect(filePaths).toContainEqual( + expect.stringContaining('oauth.service.spec.ts'), + ); + }); + }); + + describe('getRbacFiles', () => { + it('should return RBAC files', () => { + const config: AuthGenerateConfig = { + features: [AuthFeature.RBAC], + options: { skipSpec: false, flat: false, dryRun: false }, + }; + + const files = AuthGeneratorService.getRbacFiles(config); + const filePaths = files.map((f) => f.filePath); + + expect(filePaths).toContainEqual( + expect.stringContaining('role.enum.ts'), + ); + expect(filePaths).toContainEqual( + expect.stringContaining('roles.decorator.ts'), + ); + expect(filePaths).toContainEqual( + expect.stringContaining('roles.guard.ts'), + ); + expect(filePaths).toContainEqual( + expect.stringContaining('roles.guard.spec.ts'), + ); + }); + + it('should skip spec files when skipSpec is true', () => { + const config: AuthGenerateConfig = { + features: [AuthFeature.RBAC], + options: { skipSpec: true, flat: false, dryRun: false }, + }; + + const files = AuthGeneratorService.getRbacFiles(config); + const specFiles = files.filter((f) => f.filePath.includes('.spec.')); + + expect(specFiles).toHaveLength(0); + }); + }); + + describe('getEmailFiles', () => { + it('should return email verification files', () => { + const config: AuthGenerateConfig = { + features: [AuthFeature.EMAIL_VERIFICATION], + options: { skipSpec: false, flat: false, dryRun: false }, + }; + + const files = AuthGeneratorService.getEmailFiles(config); + const filePaths = files.map((f) => f.filePath); + + expect(filePaths).toContainEqual( + expect.stringContaining('email.module.ts'), + ); + expect(filePaths).toContainEqual( + expect.stringContaining('email.service.ts'), + ); + expect(filePaths).toContainEqual( + expect.stringContaining('email.controller.ts'), + ); + expect(filePaths).toContainEqual( + expect.stringContaining('email.service.spec.ts'), + ); + }); + }); + + describe('generate', () => { + it('should not write files in dry-run mode', () => { + const config: AuthGenerateConfig = { + features: [AuthFeature.JWT], + options: { skipSpec: false, flat: false, dryRun: true }, + }; + + const files = AuthGeneratorService.generate(config); + + expect(files.length).toBeGreaterThan(0); + expect(fs.ensureDirSync).not.toHaveBeenCalled(); + expect(fs.writeFileSync).not.toHaveBeenCalled(); + }); + + it('should write files when not in dry-run mode', () => { + const config: AuthGenerateConfig = { + features: [AuthFeature.JWT], + options: { skipSpec: false, flat: false, dryRun: false }, + }; + + AuthGeneratorService.generate(config); + + expect(fs.ensureDirSync).toHaveBeenCalled(); + expect(fs.writeFileSync).toHaveBeenCalled(); + }); + + it('should generate files for multiple features', () => { + const config: AuthGenerateConfig = { + features: [AuthFeature.JWT, AuthFeature.RBAC, AuthFeature.OAUTH], + options: { skipSpec: false, flat: false, dryRun: true }, + }; + + const files = AuthGeneratorService.generate(config); + + const filePaths = files.map((f) => f.filePath); + // JWT files + expect(filePaths).toContainEqual( + expect.stringContaining('auth.module.ts'), + ); + // RBAC files + expect(filePaths).toContainEqual( + expect.stringContaining('roles.guard.ts'), + ); + // OAuth files + expect(filePaths).toContainEqual( + expect.stringContaining('oauth.module.ts'), + ); + }); + }); +}); diff --git a/src/services/__tests__/generate.service.spec.ts b/src/services/__tests__/generate.service.spec.ts new file mode 100644 index 0000000..5dfea2b --- /dev/null +++ b/src/services/__tests__/generate.service.spec.ts @@ -0,0 +1,348 @@ +import fs from 'fs-extra'; +import path from 'path'; +import { GenerateService } from '../generate.service'; +import { Schematic } from '../../constants/enums'; +import { GenerateConfig } from '../../types/project.types'; + +jest.mock('fs-extra'); + +describe('GenerateService', () => { + const mockCwd = '/project'; + + beforeEach(() => { + jest.clearAllMocks(); + jest.spyOn(process, 'cwd').mockReturnValue(mockCwd); + (fs.existsSync as jest.Mock).mockImplementation((p: string) => { + if (p === path.join(mockCwd, 'src')) return true; + return false; + }); + }); + + afterEach(() => { + jest.restoreAllMocks(); + }); + + describe('buildFileList', () => { + it('should generate module files', () => { + const config: GenerateConfig = { + schematic: Schematic.MODULE, + name: 'users', + options: { skipSpec: false, flat: false, dryRun: false }, + }; + + const files = GenerateService.buildFileList(config); + + expect(files).toHaveLength(2); + expect(files[0].filePath).toContain('users.module.ts'); + expect(files[1].filePath).toContain('users.module.spec.ts'); + }); + + it('should generate controller files', () => { + const config: GenerateConfig = { + schematic: Schematic.CONTROLLER, + name: 'users', + options: { skipSpec: false, flat: false, dryRun: false }, + }; + + const files = GenerateService.buildFileList(config); + + expect(files).toHaveLength(2); + expect(files[0].filePath).toContain('users.controller.ts'); + expect(files[1].filePath).toContain('users.controller.spec.ts'); + }); + + it('should generate service files', () => { + const config: GenerateConfig = { + schematic: Schematic.SERVICE, + name: 'users', + options: { skipSpec: false, flat: false, dryRun: false }, + }; + + const files = GenerateService.buildFileList(config); + + expect(files).toHaveLength(2); + expect(files[0].filePath).toContain('users.service.ts'); + expect(files[1].filePath).toContain('users.service.spec.ts'); + }); + + it('should generate guard files in common/guards', () => { + const config: GenerateConfig = { + schematic: Schematic.GUARD, + name: 'auth', + options: { skipSpec: false, flat: false, dryRun: false }, + }; + + const files = GenerateService.buildFileList(config); + + expect(files).toHaveLength(2); + expect(files[0].filePath).toContain( + path.join('common', 'guards', 'auth.guard.ts'), + ); + }); + + it('should generate interceptor files in common/interceptors', () => { + const config: GenerateConfig = { + schematic: Schematic.INTERCEPTOR, + name: 'logging', + options: { skipSpec: false, flat: false, dryRun: false }, + }; + + const files = GenerateService.buildFileList(config); + + expect(files).toHaveLength(2); + expect(files[0].filePath).toContain( + path.join('common', 'interceptors', 'logging.interceptor.ts'), + ); + }); + + it('should generate pipe files in common/pipes', () => { + const config: GenerateConfig = { + schematic: Schematic.PIPE, + name: 'validation', + options: { skipSpec: false, flat: false, dryRun: false }, + }; + + const files = GenerateService.buildFileList(config); + + expect(files).toHaveLength(2); + expect(files[0].filePath).toContain( + path.join('common', 'pipes', 'validation.pipe.ts'), + ); + }); + + it('should skip spec files when skipSpec is true', () => { + const config: GenerateConfig = { + schematic: Schematic.SERVICE, + name: 'users', + options: { skipSpec: true, flat: false, dryRun: false }, + }; + + const files = GenerateService.buildFileList(config); + + expect(files).toHaveLength(1); + expect(files[0].filePath).toContain('users.service.ts'); + }); + + it('should place files in src directory when flat is true', () => { + const config: GenerateConfig = { + schematic: Schematic.SERVICE, + name: 'users', + options: { skipSpec: false, flat: true, dryRun: false }, + }; + + const files = GenerateService.buildFileList(config); + + expect(files[0].filePath).toBe( + path.join(mockCwd, 'src', 'users.service.ts'), + ); + }); + }); + + describe('generate', () => { + it('should not write files in dry-run mode', () => { + const config: GenerateConfig = { + schematic: Schematic.SERVICE, + name: 'users', + options: { skipSpec: false, flat: false, dryRun: true }, + }; + + const files = GenerateService.generate(config); + + expect(files).toHaveLength(2); + expect(fs.ensureDirSync).not.toHaveBeenCalled(); + expect(fs.writeFileSync).not.toHaveBeenCalled(); + }); + + it('should write files when not in dry-run mode', () => { + const config: GenerateConfig = { + schematic: Schematic.SERVICE, + name: 'users', + options: { skipSpec: false, flat: false, dryRun: false }, + }; + + GenerateService.generate(config); + + expect(fs.ensureDirSync).toHaveBeenCalled(); + expect(fs.writeFileSync).toHaveBeenCalled(); + }); + }); + + describe('resolveTargetDirectory', () => { + it('should use src directory when it exists', () => { + const result = GenerateService.resolveTargetDirectory( + 'users', + Schematic.SERVICE, + false, + ); + + expect(result).toBe(path.join(mockCwd, 'src', 'users')); + }); + + it('should use cwd when src does not exist', () => { + (fs.existsSync as jest.Mock).mockReturnValue(false); + + const result = GenerateService.resolveTargetDirectory( + 'users', + Schematic.SERVICE, + false, + ); + + expect(result).toBe(path.join(mockCwd, 'users')); + }); + + it('should place guards in common/guards', () => { + const result = GenerateService.resolveTargetDirectory( + 'auth', + Schematic.GUARD, + false, + ); + + expect(result).toBe(path.join(mockCwd, 'src', 'common', 'guards')); + }); + + it('should place interceptors in common/interceptors', () => { + const result = GenerateService.resolveTargetDirectory( + 'logging', + Schematic.INTERCEPTOR, + false, + ); + + expect(result).toBe( + path.join(mockCwd, 'src', 'common', 'interceptors'), + ); + }); + + it('should place pipes in common/pipes', () => { + const result = GenerateService.resolveTargetDirectory( + 'validation', + Schematic.PIPE, + false, + ); + + expect(result).toBe(path.join(mockCwd, 'src', 'common', 'pipes')); + }); + + it('should return base dir when flat is true', () => { + const result = GenerateService.resolveTargetDirectory( + 'users', + Schematic.SERVICE, + true, + ); + + expect(result).toBe(path.join(mockCwd, 'src')); + }); + }); + + describe('getSuffix', () => { + it('should return correct suffix for each schematic', () => { + expect(GenerateService.getSuffix(Schematic.MODULE)).toBe('module'); + expect(GenerateService.getSuffix(Schematic.CONTROLLER)).toBe('controller'); + expect(GenerateService.getSuffix(Schematic.SERVICE)).toBe('service'); + expect(GenerateService.getSuffix(Schematic.GUARD)).toBe('guard'); + expect(GenerateService.getSuffix(Schematic.INTERCEPTOR)).toBe('interceptor'); + expect(GenerateService.getSuffix(Schematic.PIPE)).toBe('pipe'); + }); + }); + + describe('getModuleArrayKey', () => { + it('should return controllers for controller schematic', () => { + expect(GenerateService.getModuleArrayKey(Schematic.CONTROLLER)).toBe( + 'controllers', + ); + }); + + it('should return providers for service schematic', () => { + expect(GenerateService.getModuleArrayKey(Schematic.SERVICE)).toBe( + 'providers', + ); + }); + + it('should return providers for guard schematic', () => { + expect(GenerateService.getModuleArrayKey(Schematic.GUARD)).toBe( + 'providers', + ); + }); + + it('should return providers for interceptor schematic', () => { + expect(GenerateService.getModuleArrayKey(Schematic.INTERCEPTOR)).toBe( + 'providers', + ); + }); + + it('should return providers for pipe schematic', () => { + expect(GenerateService.getModuleArrayKey(Schematic.PIPE)).toBe( + 'providers', + ); + }); + }); + + describe('addToModuleArray', () => { + it('should add to existing array with items', () => { + const content = `@Module({ + controllers: [AppController], + providers: [AppService], +})`; + + const result = GenerateService.addToModuleArray( + content, + 'controllers', + 'UsersController', + ); + + expect(result).toContain('AppController, UsersController'); + }); + + it('should add to empty array', () => { + const content = `@Module({ + controllers: [], + providers: [AppService], +})`; + + const result = GenerateService.addToModuleArray( + content, + 'controllers', + 'UsersController', + ); + + expect(result).toContain('UsersController'); + }); + + it('should add array key if it does not exist', () => { + const content = `@Module({ + providers: [AppService], +})`; + + const result = GenerateService.addToModuleArray( + content, + 'controllers', + 'UsersController', + ); + + expect(result).toContain('controllers: [UsersController]'); + }); + }); + + describe('toPascalCase', () => { + it('should convert kebab-case to PascalCase', () => { + expect(GenerateService.toPascalCase('user-profile')).toBe('UserProfile'); + }); + + it('should capitalize single word', () => { + expect(GenerateService.toPascalCase('users')).toBe('Users'); + }); + + it('should handle multiple hyphens', () => { + expect(GenerateService.toPascalCase('my-long-name')).toBe('MyLongName'); + }); + }); + + describe('getBaseName', () => { + it('should return the base name from a simple name', () => { + expect(GenerateService.getBaseName('users')).toBe('users'); + }); + + it('should return the last segment from a path', () => { + expect(GenerateService.getBaseName('users/admin')).toBe('admin'); + }); + }); +}); diff --git a/src/services/auth-generator.service.ts b/src/services/auth-generator.service.ts new file mode 100644 index 0000000..de7b0be --- /dev/null +++ b/src/services/auth-generator.service.ts @@ -0,0 +1,216 @@ +import fs from 'fs-extra'; +import path from 'path'; +import { AuthFeature } from '../constants/enums'; +import { AuthGenerateConfig } from '../types/project.types'; +import { + createJwtAuthModuleTemplate, + createJwtAuthServiceTemplate, + createJwtAuthControllerTemplate, + createJwtStrategyTemplate, + createLocalStrategyTemplate, + createJwtAuthGuardTemplate, + createLocalAuthGuardTemplate, + createJwtAuthServiceSpecTemplate, + createJwtAuthControllerSpecTemplate, + createOAuthModuleTemplate, + createOAuthServiceTemplate, + createOAuthControllerTemplate, + createGoogleStrategyTemplate, + createOAuthServiceSpecTemplate, + createRolesEnumTemplate, + createRolesDecoratorTemplate, + createRolesGuardTemplate, + createRolesGuardSpecTemplate, + createEmailModuleTemplate, + createEmailServiceTemplate, + createEmailControllerTemplate, + createEmailServiceSpecTemplate, +} from '../templates/auth'; + +export interface GeneratedFile { + filePath: string; + content: string; +} + +export class AuthGeneratorService { + static generate(config: AuthGenerateConfig): GeneratedFile[] { + const allFiles: GeneratedFile[] = []; + + for (const feature of config.features) { + const files = this.getFilesForFeature(feature, config); + allFiles.push(...files); + } + + if (config.options.dryRun) { + return allFiles; + } + + for (const file of allFiles) { + fs.ensureDirSync(path.dirname(file.filePath)); + fs.writeFileSync(file.filePath, file.content); + } + + return allFiles; + } + + static getFilesForFeature( + feature: AuthFeature, + config: AuthGenerateConfig, + ): GeneratedFile[] { + switch (feature) { + case AuthFeature.JWT: + return this.getJwtFiles(config); + case AuthFeature.OAUTH: + return this.getOAuthFiles(config); + case AuthFeature.RBAC: + return this.getRbacFiles(config); + case AuthFeature.EMAIL_VERIFICATION: + return this.getEmailFiles(config); + default: + return []; + } + } + + static resolveAuthDirectory(): string { + const cwd = process.cwd(); + const srcPath = path.join(cwd, 'src'); + const baseDir = fs.existsSync(srcPath) ? srcPath : cwd; + return path.join(baseDir, 'auth'); + } + + static getJwtFiles(config: AuthGenerateConfig): GeneratedFile[] { + const authDir = this.resolveAuthDirectory(); + const files: GeneratedFile[] = [ + { + filePath: path.join(authDir, 'auth.module.ts'), + content: createJwtAuthModuleTemplate(), + }, + { + filePath: path.join(authDir, 'auth.service.ts'), + content: createJwtAuthServiceTemplate(), + }, + { + filePath: path.join(authDir, 'auth.controller.ts'), + content: createJwtAuthControllerTemplate(), + }, + { + filePath: path.join(authDir, 'strategies', 'jwt.strategy.ts'), + content: createJwtStrategyTemplate(), + }, + { + filePath: path.join(authDir, 'strategies', 'local.strategy.ts'), + content: createLocalStrategyTemplate(), + }, + { + filePath: path.join(authDir, 'guards', 'jwt-auth.guard.ts'), + content: createJwtAuthGuardTemplate(), + }, + { + filePath: path.join(authDir, 'guards', 'local-auth.guard.ts'), + content: createLocalAuthGuardTemplate(), + }, + ]; + + if (!config.options.skipSpec) { + files.push( + { + filePath: path.join(authDir, 'auth.service.spec.ts'), + content: createJwtAuthServiceSpecTemplate(), + }, + { + filePath: path.join(authDir, 'auth.controller.spec.ts'), + content: createJwtAuthControllerSpecTemplate(), + }, + ); + } + + return files; + } + + static getOAuthFiles(config: AuthGenerateConfig): GeneratedFile[] { + const authDir = this.resolveAuthDirectory(); + const oauthDir = path.join(authDir, 'oauth'); + const files: GeneratedFile[] = [ + { + filePath: path.join(oauthDir, 'oauth.module.ts'), + content: createOAuthModuleTemplate(), + }, + { + filePath: path.join(oauthDir, 'oauth.service.ts'), + content: createOAuthServiceTemplate(), + }, + { + filePath: path.join(oauthDir, 'oauth.controller.ts'), + content: createOAuthControllerTemplate(), + }, + { + filePath: path.join(oauthDir, 'strategies', 'google.strategy.ts'), + content: createGoogleStrategyTemplate(), + }, + ]; + + if (!config.options.skipSpec) { + files.push({ + filePath: path.join(oauthDir, 'oauth.service.spec.ts'), + content: createOAuthServiceSpecTemplate(), + }); + } + + return files; + } + + static getRbacFiles(config: AuthGenerateConfig): GeneratedFile[] { + const authDir = this.resolveAuthDirectory(); + const files: GeneratedFile[] = [ + { + filePath: path.join(authDir, 'enums', 'role.enum.ts'), + content: createRolesEnumTemplate(), + }, + { + filePath: path.join(authDir, 'decorators', 'roles.decorator.ts'), + content: createRolesDecoratorTemplate(), + }, + { + filePath: path.join(authDir, 'guards', 'roles.guard.ts'), + content: createRolesGuardTemplate(), + }, + ]; + + if (!config.options.skipSpec) { + files.push({ + filePath: path.join(authDir, 'guards', 'roles.guard.spec.ts'), + content: createRolesGuardSpecTemplate(), + }); + } + + return files; + } + + static getEmailFiles(config: AuthGenerateConfig): GeneratedFile[] { + const authDir = this.resolveAuthDirectory(); + const emailDir = path.join(authDir, 'email'); + const files: GeneratedFile[] = [ + { + filePath: path.join(emailDir, 'email.module.ts'), + content: createEmailModuleTemplate(), + }, + { + filePath: path.join(emailDir, 'email.service.ts'), + content: createEmailServiceTemplate(), + }, + { + filePath: path.join(emailDir, 'email.controller.ts'), + content: createEmailControllerTemplate(), + }, + ]; + + if (!config.options.skipSpec) { + files.push({ + filePath: path.join(emailDir, 'email.service.spec.ts'), + content: createEmailServiceSpecTemplate(), + }); + } + + return files; + } +} diff --git a/src/services/file-generator.service.ts b/src/services/file-generator.service.ts index e2f86d7..694f16c 100644 --- a/src/services/file-generator.service.ts +++ b/src/services/file-generator.service.ts @@ -20,8 +20,9 @@ import { createAppE2ESpec } from '../templates/app-e2e-spec.template'; import { createJestE2EConfig } from '../templates/jest-e2e-config.template'; import { createReadme } from '../templates/readme.template'; import { createDatabaseModule } from '../templates/database-module.template'; -import { Database, ORM } from '../constants/enums'; +import { Database, ORM, AuthFeature } from '../constants/enums'; import { PackageInstallerService } from './package-installer.service'; +import { AuthGeneratorService } from './auth-generator.service'; export class FileGeneratorService { static generateBaseFiles(config: ProjectConfig): void { @@ -179,4 +180,21 @@ export class FileGeneratorService { config.answers.database, ); } + + static generateAuthFiles(config: ProjectConfig): void { + if (!config.answers.useAuth || !config.answers.authFeatures?.length) return; + + // Temporarily change cwd to project path for auth generator + const originalCwd = process.cwd(); + process.chdir(config.path); + + try { + AuthGeneratorService.generate({ + features: config.answers.authFeatures, + options: { skipSpec: false, flat: false, dryRun: false }, + }); + } finally { + process.chdir(originalCwd); + } + } } diff --git a/src/services/generate.service.ts b/src/services/generate.service.ts new file mode 100644 index 0000000..2d07f8c --- /dev/null +++ b/src/services/generate.service.ts @@ -0,0 +1,287 @@ +import fs from 'fs-extra'; +import path from 'path'; +import { Schematic } from '../constants/enums'; +import { GenerateConfig } from '../types/project.types'; +import { + createModuleTemplate, + createModuleSpecTemplate, + createControllerTemplate, + createControllerSpecTemplate, + createServiceTemplate, + createServiceSpecTemplate, + createGuardTemplate, + createGuardSpecTemplate, + createInterceptorTemplate, + createInterceptorSpecTemplate, + createPipeTemplate, + createPipeSpecTemplate, +} from '../templates/generate'; + +export interface GeneratedFile { + filePath: string; + content: string; +} + +export class GenerateService { + static generate(config: GenerateConfig): GeneratedFile[] { + const files = this.buildFileList(config); + + if (config.options.dryRun) { + return files; + } + + for (const file of files) { + fs.ensureDirSync(path.dirname(file.filePath)); + fs.writeFileSync(file.filePath, file.content); + } + + // Auto-update parent module (skip for module schematic) + if (config.schematic !== Schematic.MODULE) { + this.updateParentModule(config); + } + + return files; + } + + static buildFileList(config: GenerateConfig): GeneratedFile[] { + const { schematic, name, options } = config; + const targetDir = this.resolveTargetDirectory( + name, + schematic, + options.flat, + ); + const files: GeneratedFile[] = []; + const templateFn = this.getTemplateFn(schematic); + const specTemplateFn = this.getSpecTemplateFn(schematic); + const suffix = this.getSuffix(schematic); + const baseName = this.getBaseName(name); + + files.push({ + filePath: path.join(targetDir, `${baseName}.${suffix}.ts`), + content: templateFn(baseName), + }); + + if (!options.skipSpec) { + files.push({ + filePath: path.join(targetDir, `${baseName}.${suffix}.spec.ts`), + content: specTemplateFn(baseName), + }); + } + + return files; + } + + static resolveTargetDirectory( + name: string, + schematic: Schematic, + flat: boolean, + ): string { + const cwd = process.cwd(); + const baseName = this.getBaseName(name); + const srcPath = path.join(cwd, 'src'); + const baseDir = fs.existsSync(srcPath) ? srcPath : cwd; + + if (flat) { + return baseDir; + } + + switch (schematic) { + case Schematic.MODULE: + case Schematic.CONTROLLER: + case Schematic.SERVICE: + return path.join(baseDir, baseName); + case Schematic.GUARD: + return path.join(baseDir, 'common', 'guards'); + case Schematic.INTERCEPTOR: + return path.join(baseDir, 'common', 'interceptors'); + case Schematic.PIPE: + return path.join(baseDir, 'common', 'pipes'); + default: + return path.join(baseDir, baseName); + } + } + + static getBaseName(name: string): string { + return path.basename(name).toLowerCase(); + } + + static getSuffix(schematic: Schematic): string { + switch (schematic) { + case Schematic.MODULE: + return 'module'; + case Schematic.CONTROLLER: + return 'controller'; + case Schematic.SERVICE: + return 'service'; + case Schematic.GUARD: + return 'guard'; + case Schematic.INTERCEPTOR: + return 'interceptor'; + case Schematic.PIPE: + return 'pipe'; + default: + return 'service'; + } + } + + static getTemplateFn(schematic: Schematic): (name: string) => string { + switch (schematic) { + case Schematic.MODULE: + return createModuleTemplate; + case Schematic.CONTROLLER: + return createControllerTemplate; + case Schematic.SERVICE: + return createServiceTemplate; + case Schematic.GUARD: + return createGuardTemplate; + case Schematic.INTERCEPTOR: + return createInterceptorTemplate; + case Schematic.PIPE: + return createPipeTemplate; + default: + return createServiceTemplate; + } + } + + static getSpecTemplateFn(schematic: Schematic): (name: string) => string { + switch (schematic) { + case Schematic.MODULE: + return createModuleSpecTemplate; + case Schematic.CONTROLLER: + return createControllerSpecTemplate; + case Schematic.SERVICE: + return createServiceSpecTemplate; + case Schematic.GUARD: + return createGuardSpecTemplate; + case Schematic.INTERCEPTOR: + return createInterceptorSpecTemplate; + case Schematic.PIPE: + return createPipeSpecTemplate; + default: + return createServiceSpecTemplate; + } + } + + static updateParentModule(config: GenerateConfig): void { + const { schematic, name } = config; + const baseName = this.getBaseName(name); + const className = this.toPascalCase(baseName); + const suffix = this.getSuffix(schematic); + const suffixPascal = suffix.charAt(0).toUpperCase() + suffix.slice(1); + + const modulePath = this.findNearestModule(config); + if (!modulePath) return; + + let moduleContent = fs.readFileSync(modulePath, 'utf-8'); + + const componentClass = `${className}${suffixPascal}`; + const relativePath = this.getRelativeImportPath(modulePath, config); + const importStatement = `import { ${componentClass} } from '${relativePath}';`; + + // Add import at the top (after last import) + const lastImportIndex = moduleContent.lastIndexOf('import '); + const lastImportEnd = moduleContent.indexOf('\n', lastImportIndex); + moduleContent = + moduleContent.slice(0, lastImportEnd + 1) + + importStatement + + '\n' + + moduleContent.slice(lastImportEnd + 1); + + // Add to the appropriate decorator array + const arrayKey = this.getModuleArrayKey(schematic); + moduleContent = this.addToModuleArray(moduleContent, arrayKey, componentClass); + + fs.writeFileSync(modulePath, moduleContent); + } + + static findNearestModule(config: GenerateConfig): string | null { + const cwd = process.cwd(); + const srcPath = path.join(cwd, 'src'); + const baseName = this.getBaseName(config.name); + + const componentModulePath = path.join( + srcPath, + baseName, + `${baseName}.module.ts`, + ); + if (fs.existsSync(componentModulePath)) { + return componentModulePath; + } + + const appModulePath = path.join(srcPath, 'app.module.ts'); + if (fs.existsSync(appModulePath)) { + return appModulePath; + } + + return null; + } + + static getRelativeImportPath( + modulePath: string, + config: GenerateConfig, + ): string { + const { schematic, name, options } = config; + const baseName = this.getBaseName(name); + const suffix = this.getSuffix(schematic); + const targetDir = this.resolveTargetDirectory(name, schematic, options.flat); + const filePath = path.join(targetDir, `${baseName}.${suffix}`); + + let relativePath = path.relative(path.dirname(modulePath), filePath); + if (!relativePath.startsWith('.')) { + relativePath = './' + relativePath; + } + relativePath = relativePath.replace(/\\/g, '/'); + return relativePath; + } + + static getModuleArrayKey(schematic: Schematic): string { + switch (schematic) { + case Schematic.CONTROLLER: + return 'controllers'; + case Schematic.SERVICE: + case Schematic.GUARD: + case Schematic.INTERCEPTOR: + case Schematic.PIPE: + return 'providers'; + default: + return 'providers'; + } + } + + static addToModuleArray( + content: string, + arrayKey: string, + className: string, + ): string { + const arrayRegex = new RegExp(`(${arrayKey}:\\s*\\[)([^\\]]*)`, 's'); + const match = content.match(arrayRegex); + + if (match) { + const existingItems = match[2].trim(); + if (existingItems) { + const replacement = `${match[1]}${existingItems}, ${className}`; + content = content.replace(arrayRegex, replacement); + } else { + const replacement = `${match[1]}${className}`; + content = content.replace(arrayRegex, replacement); + } + } else { + const moduleDecoratorRegex = /@Module\(\{([^}]*)\}\)/s; + const moduleMatch = content.match(moduleDecoratorRegex); + if (moduleMatch) { + const existingContent = moduleMatch[1].trimEnd(); + const newContent = `${existingContent}\n ${arrayKey}: [${className}],\n`; + content = content.replace(moduleDecoratorRegex, `@Module({${newContent}})`); + } + } + + return content; + } + + static toPascalCase(str: string): string { + return str + .split('-') + .map((part) => part.charAt(0).toUpperCase() + part.slice(1)) + .join(''); + } +} diff --git a/src/services/prompts.service.ts b/src/services/prompts.service.ts index ee0bb4d..1e39967 100644 --- a/src/services/prompts.service.ts +++ b/src/services/prompts.service.ts @@ -1,6 +1,6 @@ import inquirer from 'inquirer'; import { ProjectAnswers } from '../types/project.types'; -import { PackageManager, Database, ORM } from '../constants/enums'; +import { PackageManager, Database, ORM, AuthFeature } from '../constants/enums'; export class PromptsService { static async getProjectDetails( @@ -39,6 +39,12 @@ export class PromptsService { message: 'Add Docker support?', default: false, }, + { + type: 'confirm', + name: 'useAuth', + message: 'Add authentication setup?', + default: false, + }, ]); // Ask for ORM choice only if MySQL or PostgreSQL is selected @@ -58,6 +64,32 @@ export class PromptsService { answers.orm = ormAnswer.orm; } + // Ask for auth features if authentication is enabled + if (answers.useAuth) { + const authAnswer = await inquirer.prompt([ + { + type: 'checkbox', + name: 'authFeatures', + message: 'Which authentication features would you like?', + choices: [ + { name: 'JWT Authentication', value: AuthFeature.JWT }, + { name: 'OAuth (Google)', value: AuthFeature.OAUTH }, + { + name: 'Role-Based Access Control (RBAC)', + value: AuthFeature.RBAC, + }, + { + name: 'Email Verification & Password Reset', + value: AuthFeature.EMAIL_VERIFICATION, + }, + ], + validate: (input: AuthFeature[]) => + input.length > 0 || 'Please select at least one feature', + }, + ]); + answers.authFeatures = authAnswer.authFeatures; + } + return answers as ProjectAnswers; } } diff --git a/src/templates/auth/email-controller.template.ts b/src/templates/auth/email-controller.template.ts new file mode 100644 index 0000000..cdb2797 --- /dev/null +++ b/src/templates/auth/email-controller.template.ts @@ -0,0 +1,41 @@ +export function createEmailControllerTemplate(): string { + return `import { + Controller, + Post, + Body, + HttpCode, + HttpStatus, +} from '@nestjs/common'; +import { EmailService } from './email.service'; + +@Controller('auth') +export class EmailController { + constructor(private readonly emailService: EmailService) {} + + @Post('verify-email') + @HttpCode(HttpStatus.OK) + async verifyEmail(@Body() body: { token: string }) { + // TODO: Validate token and mark email as verified + return { message: 'Email verified successfully' }; + } + + @Post('forgot-password') + @HttpCode(HttpStatus.OK) + async forgotPassword(@Body() body: { email: string }) { + // TODO: Generate reset token and store it + const token = 'generated-reset-token'; + await this.emailService.sendPasswordResetEmail(body.email, token); + return { message: 'Password reset email sent' }; + } + + @Post('reset-password') + @HttpCode(HttpStatus.OK) + async resetPassword( + @Body() body: { token: string; password: string }, + ) { + // TODO: Validate token and update password + return { message: 'Password reset successfully' }; + } +} +`; +} diff --git a/src/templates/auth/email-spec.template.ts b/src/templates/auth/email-spec.template.ts new file mode 100644 index 0000000..923c61b --- /dev/null +++ b/src/templates/auth/email-spec.template.ts @@ -0,0 +1,50 @@ +export function createEmailServiceSpecTemplate(): string { + return `import { Test, TestingModule } from '@nestjs/testing'; +import { ConfigService } from '@nestjs/config'; +import { EmailService } from './email.service'; + +describe('EmailService', () => { + let service: EmailService; + let configService: ConfigService; + + beforeEach(async () => { + const module: TestingModule = await Test.createTestingModule({ + providers: [ + EmailService, + { + provide: ConfigService, + useValue: { + get: jest.fn().mockReturnValue('http://localhost:3000'), + }, + }, + ], + }).compile(); + + service = module.get(EmailService); + configService = module.get(ConfigService); + }); + + it('should be defined', () => { + expect(service).toBeDefined(); + }); + + describe('sendVerificationEmail', () => { + it('should send verification email', async () => { + const consoleSpy = jest.spyOn(console, 'log').mockImplementation(); + await service.sendVerificationEmail('test@example.com', 'token123'); + expect(consoleSpy).toHaveBeenCalled(); + consoleSpy.mockRestore(); + }); + }); + + describe('sendPasswordResetEmail', () => { + it('should send password reset email', async () => { + const consoleSpy = jest.spyOn(console, 'log').mockImplementation(); + await service.sendPasswordResetEmail('test@example.com', 'token123'); + expect(consoleSpy).toHaveBeenCalled(); + consoleSpy.mockRestore(); + }); + }); +}); +`; +} diff --git a/src/templates/auth/email.template.ts b/src/templates/auth/email.template.ts new file mode 100644 index 0000000..328af46 --- /dev/null +++ b/src/templates/auth/email.template.ts @@ -0,0 +1,50 @@ +export function createEmailModuleTemplate(): string { + return `import { Module } from '@nestjs/common'; +import { ConfigModule } from '@nestjs/config'; +import { EmailService } from './email.service'; +import { EmailController } from './email.controller'; + +@Module({ + imports: [ConfigModule], + controllers: [EmailController], + providers: [EmailService], + exports: [EmailService], +}) +export class EmailModule {} +`; +} + +export function createEmailServiceTemplate(): string { + return `import { Injectable } from '@nestjs/common'; +import { ConfigService } from '@nestjs/config'; + +@Injectable() +export class EmailService { + constructor(private readonly configService: ConfigService) {} + + async sendVerificationEmail( + email: string, + token: string, + ): Promise { + const appUrl = this.configService.get('APP_URL'); + const verificationUrl = \`\${appUrl}/auth/verify?token=\${token}\`; + + // TODO: Integrate with email provider (e.g., nodemailer, SendGrid) + console.log(\`Verification email sent to \${email}\`); + console.log(\`Verification URL: \${verificationUrl}\`); + } + + async sendPasswordResetEmail( + email: string, + token: string, + ): Promise { + const appUrl = this.configService.get('APP_URL'); + const resetUrl = \`\${appUrl}/auth/reset-password?token=\${token}\`; + + // TODO: Integrate with email provider + console.log(\`Password reset email sent to \${email}\`); + console.log(\`Reset URL: \${resetUrl}\`); + } +} +`; +} diff --git a/src/templates/auth/index.ts b/src/templates/auth/index.ts new file mode 100644 index 0000000..0042b5c --- /dev/null +++ b/src/templates/auth/index.ts @@ -0,0 +1,35 @@ +export { + createJwtAuthModuleTemplate, + createJwtAuthServiceTemplate, + createJwtAuthControllerTemplate, + createJwtStrategyTemplate, + createLocalStrategyTemplate, + createJwtAuthGuardTemplate, + createLocalAuthGuardTemplate, + createJwtAuthServiceSpecTemplate, + createJwtAuthControllerSpecTemplate, +} from './jwt.template'; + +export { + createOAuthModuleTemplate, + createOAuthServiceTemplate, + createOAuthControllerTemplate, + createGoogleStrategyTemplate, + createOAuthServiceSpecTemplate, +} from './oauth.template'; + +export { + createRolesEnumTemplate, + createRolesDecoratorTemplate, + createRolesGuardTemplate, + createRolesGuardSpecTemplate, +} from './rbac.template'; + +export { + createEmailModuleTemplate, + createEmailServiceTemplate, +} from './email.template'; + +export { createEmailControllerTemplate } from './email-controller.template'; + +export { createEmailServiceSpecTemplate } from './email-spec.template'; diff --git a/src/templates/auth/jwt.template.ts b/src/templates/auth/jwt.template.ts new file mode 100644 index 0000000..2febc53 --- /dev/null +++ b/src/templates/auth/jwt.template.ts @@ -0,0 +1,298 @@ +export function createJwtAuthModuleTemplate(): string { + return `import { Module } from '@nestjs/common'; +import { JwtModule } from '@nestjs/jwt'; +import { PassportModule } from '@nestjs/passport'; +import { ConfigModule, ConfigService } from '@nestjs/config'; +import { AuthService } from './auth.service'; +import { AuthController } from './auth.controller'; +import { JwtStrategy } from './strategies/jwt.strategy'; +import { LocalStrategy } from './strategies/local.strategy'; + +@Module({ + imports: [ + PassportModule.register({ defaultStrategy: 'jwt' }), + JwtModule.registerAsync({ + imports: [ConfigModule], + useFactory: (configService: ConfigService) => ({ + secret: configService.get('JWT_SECRET'), + signOptions: { + expiresIn: configService.get('JWT_EXPIRATION', '7d'), + }, + }), + inject: [ConfigService], + }), + ], + controllers: [AuthController], + providers: [AuthService, JwtStrategy, LocalStrategy], + exports: [AuthService, JwtModule], +}) +export class AuthModule {} +`; +} + +export function createJwtAuthServiceTemplate(): string { + return `import { Injectable, UnauthorizedException } from '@nestjs/common'; +import { JwtService } from '@nestjs/jwt'; + +export interface JwtPayload { + sub: string; + email: string; +} + +export interface LoginDto { + email: string; + password: string; +} + +export interface RegisterDto { + email: string; + password: string; + name: string; +} + +@Injectable() +export class AuthService { + constructor(private readonly jwtService: JwtService) {} + + async validateUser(email: string, password: string): Promise { + // TODO: Replace with actual user lookup and password comparison + // Example: const user = await this.usersService.findByEmail(email); + // if (user && await bcrypt.compare(password, user.password)) { + // const { password, ...result } = user; + // return result; + // } + throw new UnauthorizedException('Invalid credentials'); + } + + async login(user: any): Promise<{ accessToken: string }> { + const payload: JwtPayload = { sub: user.id, email: user.email }; + return { + accessToken: this.jwtService.sign(payload), + }; + } + + async register(registerDto: RegisterDto): Promise<{ accessToken: string }> { + // TODO: Replace with actual user creation logic + // Example: const user = await this.usersService.create(registerDto); + const payload: JwtPayload = { sub: 'user-id', email: registerDto.email }; + return { + accessToken: this.jwtService.sign(payload), + }; + } +} +`; +} + +export function createJwtAuthControllerTemplate(): string { + return `import { Controller, Post, Body, UseGuards, Request } from '@nestjs/common'; +import { AuthService, LoginDto, RegisterDto } from './auth.service'; +import { LocalAuthGuard } from './guards/local-auth.guard'; +import { JwtAuthGuard } from './guards/jwt-auth.guard'; + +@Controller('auth') +export class AuthController { + constructor(private readonly authService: AuthService) {} + + @UseGuards(LocalAuthGuard) + @Post('login') + async login(@Request() req: any) { + return this.authService.login(req.user); + } + + @Post('register') + async register(@Body() registerDto: RegisterDto) { + return this.authService.register(registerDto); + } + + @UseGuards(JwtAuthGuard) + @Post('profile') + getProfile(@Request() req: any) { + return req.user; + } +} +`; +} + +export function createJwtStrategyTemplate(): string { + return `import { Injectable } from '@nestjs/common'; +import { PassportStrategy } from '@nestjs/passport'; +import { ExtractJwt, Strategy } from 'passport-jwt'; +import { ConfigService } from '@nestjs/config'; +import { JwtPayload } from '../auth.service'; + +@Injectable() +export class JwtStrategy extends PassportStrategy(Strategy) { + constructor(private readonly configService: ConfigService) { + super({ + jwtFromRequest: ExtractJwt.fromAuthHeaderAsBearerToken(), + ignoreExpiration: false, + secretOrKey: configService.get('JWT_SECRET'), + }); + } + + async validate(payload: JwtPayload) { + return { id: payload.sub, email: payload.email }; + } +} +`; +} + +export function createLocalStrategyTemplate(): string { + return `import { Injectable, UnauthorizedException } from '@nestjs/common'; +import { PassportStrategy } from '@nestjs/passport'; +import { Strategy } from 'passport-local'; +import { AuthService } from '../auth.service'; + +@Injectable() +export class LocalStrategy extends PassportStrategy(Strategy) { + constructor(private readonly authService: AuthService) { + super({ usernameField: 'email' }); + } + + async validate(email: string, password: string): Promise { + const user = await this.authService.validateUser(email, password); + if (!user) { + throw new UnauthorizedException(); + } + return user; + } +} +`; +} + +export function createJwtAuthGuardTemplate(): string { + return `import { Injectable } from '@nestjs/common'; +import { AuthGuard } from '@nestjs/passport'; + +@Injectable() +export class JwtAuthGuard extends AuthGuard('jwt') {} +`; +} + +export function createLocalAuthGuardTemplate(): string { + return `import { Injectable } from '@nestjs/common'; +import { AuthGuard } from '@nestjs/passport'; + +@Injectable() +export class LocalAuthGuard extends AuthGuard('local') {} +`; +} + +export function createJwtAuthServiceSpecTemplate(): string { + return `import { Test, TestingModule } from '@nestjs/testing'; +import { JwtService } from '@nestjs/jwt'; +import { AuthService } from './auth.service'; +import { UnauthorizedException } from '@nestjs/common'; + +describe('AuthService', () => { + let service: AuthService; + let jwtService: JwtService; + + beforeEach(async () => { + const module: TestingModule = await Test.createTestingModule({ + providers: [ + AuthService, + { + provide: JwtService, + useValue: { + sign: jest.fn().mockReturnValue('test-token'), + }, + }, + ], + }).compile(); + + service = module.get(AuthService); + jwtService = module.get(JwtService); + }); + + it('should be defined', () => { + expect(service).toBeDefined(); + }); + + describe('login', () => { + it('should return an access token', async () => { + const user = { id: '1', email: 'test@example.com' }; + const result = await service.login(user); + expect(result).toEqual({ accessToken: 'test-token' }); + expect(jwtService.sign).toHaveBeenCalledWith({ + sub: '1', + email: 'test@example.com', + }); + }); + }); + + describe('register', () => { + it('should return an access token', async () => { + const dto = { email: 'test@example.com', password: 'pass', name: 'Test' }; + const result = await service.register(dto); + expect(result).toEqual({ accessToken: 'test-token' }); + }); + }); + + describe('validateUser', () => { + it('should throw UnauthorizedException for invalid credentials', async () => { + await expect( + service.validateUser('test@example.com', 'wrong'), + ).rejects.toThrow(UnauthorizedException); + }); + }); +}); +`; +} + +export function createJwtAuthControllerSpecTemplate(): string { + return `import { Test, TestingModule } from '@nestjs/testing'; +import { AuthController } from './auth.controller'; +import { AuthService } from './auth.service'; + +describe('AuthController', () => { + let controller: AuthController; + let authService: AuthService; + + beforeEach(async () => { + const module: TestingModule = await Test.createTestingModule({ + controllers: [AuthController], + providers: [ + { + provide: AuthService, + useValue: { + login: jest.fn().mockResolvedValue({ accessToken: 'test-token' }), + register: jest.fn().mockResolvedValue({ accessToken: 'test-token' }), + }, + }, + ], + }).compile(); + + controller = module.get(AuthController); + authService = module.get(AuthService); + }); + + it('should be defined', () => { + expect(controller).toBeDefined(); + }); + + describe('login', () => { + it('should return an access token', async () => { + const req = { user: { id: '1', email: 'test@example.com' } }; + const result = await controller.login(req); + expect(result).toEqual({ accessToken: 'test-token' }); + }); + }); + + describe('register', () => { + it('should return an access token', async () => { + const dto = { email: 'test@example.com', password: 'pass', name: 'Test' }; + const result = await controller.register(dto); + expect(result).toEqual({ accessToken: 'test-token' }); + }); + }); + + describe('getProfile', () => { + it('should return the user from request', () => { + const req = { user: { id: '1', email: 'test@example.com' } }; + expect(controller.getProfile(req)).toEqual(req.user); + }); + }); +}); +`; +} diff --git a/src/templates/auth/oauth.template.ts b/src/templates/auth/oauth.template.ts new file mode 100644 index 0000000..b4f2041 --- /dev/null +++ b/src/templates/auth/oauth.template.ts @@ -0,0 +1,181 @@ +export function createOAuthModuleTemplate(): string { + return `import { Module } from '@nestjs/common'; +import { ConfigModule } from '@nestjs/config'; +import { OAuthController } from './oauth.controller'; +import { OAuthService } from './oauth.service'; +import { GoogleStrategy } from './strategies/google.strategy'; + +@Module({ + imports: [ConfigModule], + controllers: [OAuthController], + providers: [OAuthService, GoogleStrategy], + exports: [OAuthService], +}) +export class OAuthModule {} +`; +} + +export function createOAuthServiceTemplate(): string { + return `import { Injectable } from '@nestjs/common'; +import { JwtService } from '@nestjs/jwt'; + +export interface OAuthUser { + email: string; + name: string; + picture?: string; + provider: string; + providerId: string; +} + +@Injectable() +export class OAuthService { + constructor(private readonly jwtService: JwtService) {} + + async validateOAuthUser(oauthUser: OAuthUser): Promise { + // TODO: Find or create user in database + // Example: + // let user = await this.usersService.findByEmail(oauthUser.email); + // if (!user) { + // user = await this.usersService.create({ + // email: oauthUser.email, + // name: oauthUser.name, + // provider: oauthUser.provider, + // providerId: oauthUser.providerId, + // }); + // } + return oauthUser; + } + + async generateToken(user: any): Promise<{ accessToken: string }> { + const payload = { sub: user.providerId, email: user.email }; + return { + accessToken: this.jwtService.sign(payload), + }; + } +} +`; +} + +export function createOAuthControllerTemplate(): string { + return `import { Controller, Get, UseGuards, Request, Res } from '@nestjs/common'; +import { AuthGuard } from '@nestjs/passport'; +import { OAuthService } from './oauth.service'; + +@Controller('auth/oauth') +export class OAuthController { + constructor(private readonly oauthService: OAuthService) {} + + @Get('google') + @UseGuards(AuthGuard('google')) + async googleAuth() { + // Guard redirects to Google + } + + @Get('google/callback') + @UseGuards(AuthGuard('google')) + async googleAuthCallback(@Request() req: any, @Res() res: any) { + const token = await this.oauthService.generateToken(req.user); + // Redirect to frontend with token + res.redirect(\`/auth/success?token=\${token.accessToken}\`); + } +} +`; +} + +export function createGoogleStrategyTemplate(): string { + return `import { Injectable } from '@nestjs/common'; +import { PassportStrategy } from '@nestjs/passport'; +import { Strategy, VerifyCallback } from 'passport-google-oauth20'; +import { ConfigService } from '@nestjs/config'; +import { OAuthService } from '../oauth.service'; + +@Injectable() +export class GoogleStrategy extends PassportStrategy(Strategy, 'google') { + constructor( + private readonly configService: ConfigService, + private readonly oauthService: OAuthService, + ) { + super({ + clientID: configService.get('GOOGLE_CLIENT_ID'), + clientSecret: configService.get('GOOGLE_CLIENT_SECRET'), + callbackURL: configService.get('GOOGLE_CALLBACK_URL'), + scope: ['email', 'profile'], + }); + } + + async validate( + accessToken: string, + refreshToken: string, + profile: any, + done: VerifyCallback, + ): Promise { + const user = await this.oauthService.validateOAuthUser({ + email: profile.emails[0].value, + name: profile.displayName, + picture: profile.photos[0]?.value, + provider: 'google', + providerId: profile.id, + }); + done(null, user); + } +} +`; +} + +export function createOAuthServiceSpecTemplate(): string { + return `import { Test, TestingModule } from '@nestjs/testing'; +import { JwtService } from '@nestjs/jwt'; +import { OAuthService } from './oauth.service'; + +describe('OAuthService', () => { + let service: OAuthService; + let jwtService: JwtService; + + beforeEach(async () => { + const module: TestingModule = await Test.createTestingModule({ + providers: [ + OAuthService, + { + provide: JwtService, + useValue: { + sign: jest.fn().mockReturnValue('oauth-test-token'), + }, + }, + ], + }).compile(); + + service = module.get(OAuthService); + jwtService = module.get(JwtService); + }); + + it('should be defined', () => { + expect(service).toBeDefined(); + }); + + describe('validateOAuthUser', () => { + it('should return the oauth user', async () => { + const oauthUser = { + email: 'test@example.com', + name: 'Test User', + provider: 'google', + providerId: '123', + }; + const result = await service.validateOAuthUser(oauthUser); + expect(result).toEqual(oauthUser); + }); + }); + + describe('generateToken', () => { + it('should return an access token', async () => { + const user = { providerId: '123', email: 'test@example.com' }; + const result = await service.generateToken(user); + expect(result).toEqual({ accessToken: 'oauth-test-token' }); + expect(jwtService.sign).toHaveBeenCalledWith({ + sub: '123', + email: 'test@example.com', + }); + }); + }); +}); +`; +} diff --git a/src/templates/auth/rbac.template.ts b/src/templates/auth/rbac.template.ts new file mode 100644 index 0000000..2e56de3 --- /dev/null +++ b/src/templates/auth/rbac.template.ts @@ -0,0 +1,107 @@ +export function createRolesEnumTemplate(): string { + return `export enum Role { + USER = 'user', + ADMIN = 'admin', + MODERATOR = 'moderator', +} +`; +} + +export function createRolesDecoratorTemplate(): string { + return `import { SetMetadata } from '@nestjs/common'; +import { Role } from '../enums/role.enum'; + +export const ROLES_KEY = 'roles'; +export const Roles = (...roles: Role[]) => SetMetadata(ROLES_KEY, roles); +`; +} + +export function createRolesGuardTemplate(): string { + return `import { Injectable, CanActivate, ExecutionContext } from '@nestjs/common'; +import { Reflector } from '@nestjs/core'; +import { Role } from '../enums/role.enum'; +import { ROLES_KEY } from '../decorators/roles.decorator'; + +@Injectable() +export class RolesGuard implements CanActivate { + constructor(private reflector: Reflector) {} + + canActivate(context: ExecutionContext): boolean { + const requiredRoles = this.reflector.getAllAndOverride(ROLES_KEY, [ + context.getHandler(), + context.getClass(), + ]); + + if (!requiredRoles) { + return true; + } + + const { user } = context.switchToHttp().getRequest(); + return requiredRoles.some((role) => user?.roles?.includes(role)); + } +} +`; +} + +export function createRolesGuardSpecTemplate(): string { + return `import { Reflector } from '@nestjs/core'; +import { RolesGuard } from './roles.guard'; +import { Role } from '../enums/role.enum'; + +describe('RolesGuard', () => { + let guard: RolesGuard; + let reflector: Reflector; + + beforeEach(() => { + reflector = new Reflector(); + guard = new RolesGuard(reflector); + }); + + it('should be defined', () => { + expect(guard).toBeDefined(); + }); + + it('should return true when no roles are required', () => { + jest.spyOn(reflector, 'getAllAndOverride').mockReturnValue(undefined); + + const context = { + getHandler: () => ({}), + getClass: () => ({}), + switchToHttp: () => ({ + getRequest: () => ({ user: { roles: [Role.USER] } }), + }), + } as any; + + expect(guard.canActivate(context)).toBe(true); + }); + + it('should return true when user has required role', () => { + jest.spyOn(reflector, 'getAllAndOverride').mockReturnValue([Role.ADMIN]); + + const context = { + getHandler: () => ({}), + getClass: () => ({}), + switchToHttp: () => ({ + getRequest: () => ({ user: { roles: [Role.ADMIN] } }), + }), + } as any; + + expect(guard.canActivate(context)).toBe(true); + }); + + it('should return false when user lacks required role', () => { + jest.spyOn(reflector, 'getAllAndOverride').mockReturnValue([Role.ADMIN]); + + const context = { + getHandler: () => ({}), + getClass: () => ({}), + switchToHttp: () => ({ + getRequest: () => ({ user: { roles: [Role.USER] } }), + }), + } as any; + + expect(guard.canActivate(context)).toBe(false); + }); +}); +`; +} diff --git a/src/templates/generate/controller.template.ts b/src/templates/generate/controller.template.ts new file mode 100644 index 0000000..244b304 --- /dev/null +++ b/src/templates/generate/controller.template.ts @@ -0,0 +1,38 @@ +export function createControllerTemplate(name: string): string { + const className = toPascalCase(name); + return `import { Controller } from '@nestjs/common'; + +@Controller('${name}') +export class ${className}Controller {} +`; +} + +export function createControllerSpecTemplate(name: string): string { + const className = toPascalCase(name); + return `import { Test, TestingModule } from '@nestjs/testing'; +import { ${className}Controller } from './${name}.controller'; + +describe('${className}Controller', () => { + let controller: ${className}Controller; + + beforeEach(async () => { + const module: TestingModule = await Test.createTestingModule({ + controllers: [${className}Controller], + }).compile(); + + controller = module.get<${className}Controller>(${className}Controller); + }); + + it('should be defined', () => { + expect(controller).toBeDefined(); + }); +}); +`; +} + +function toPascalCase(str: string): string { + return str + .split('-') + .map((part) => part.charAt(0).toUpperCase() + part.slice(1)) + .join(''); +} diff --git a/src/templates/generate/guard.template.ts b/src/templates/generate/guard.template.ts new file mode 100644 index 0000000..75d3923 --- /dev/null +++ b/src/templates/generate/guard.template.ts @@ -0,0 +1,45 @@ +export function createGuardTemplate(name: string): string { + const className = toPascalCase(name); + return `import { CanActivate, ExecutionContext, Injectable } from '@nestjs/common'; +import { Observable } from 'rxjs'; + +@Injectable() +export class ${className}Guard implements CanActivate { + canActivate( + context: ExecutionContext, + ): boolean | Promise | Observable { + return true; + } +} +`; +} + +export function createGuardSpecTemplate(name: string): string { + const className = toPascalCase(name); + return `import { ${className}Guard } from './${name}.guard'; + +describe('${className}Guard', () => { + let guard: ${className}Guard; + + beforeEach(() => { + guard = new ${className}Guard(); + }); + + it('should be defined', () => { + expect(guard).toBeDefined(); + }); + + it('should return true', () => { + const mockContext = {} as any; + expect(guard.canActivate(mockContext)).toBe(true); + }); +}); +`; +} + +function toPascalCase(str: string): string { + return str + .split('-') + .map((part) => part.charAt(0).toUpperCase() + part.slice(1)) + .join(''); +} diff --git a/src/templates/generate/index.ts b/src/templates/generate/index.ts new file mode 100644 index 0000000..af3bf5b --- /dev/null +++ b/src/templates/generate/index.ts @@ -0,0 +1,6 @@ +export { createModuleTemplate, createModuleSpecTemplate } from './module.template'; +export { createControllerTemplate, createControllerSpecTemplate } from './controller.template'; +export { createServiceTemplate, createServiceSpecTemplate } from './service.template'; +export { createGuardTemplate, createGuardSpecTemplate } from './guard.template'; +export { createInterceptorTemplate, createInterceptorSpecTemplate } from './interceptor.template'; +export { createPipeTemplate, createPipeSpecTemplate } from './pipe.template'; diff --git a/src/templates/generate/interceptor.template.ts b/src/templates/generate/interceptor.template.ts new file mode 100644 index 0000000..67acf79 --- /dev/null +++ b/src/templates/generate/interceptor.template.ts @@ -0,0 +1,43 @@ +export function createInterceptorTemplate(name: string): string { + const className = toPascalCase(name); + return `import { + CallHandler, + ExecutionContext, + Injectable, + NestInterceptor, +} from '@nestjs/common'; +import { Observable } from 'rxjs'; + +@Injectable() +export class ${className}Interceptor implements NestInterceptor { + intercept(context: ExecutionContext, next: CallHandler): Observable { + return next.handle(); + } +} +`; +} + +export function createInterceptorSpecTemplate(name: string): string { + const className = toPascalCase(name); + return `import { ${className}Interceptor } from './${name}.interceptor'; + +describe('${className}Interceptor', () => { + let interceptor: ${className}Interceptor; + + beforeEach(() => { + interceptor = new ${className}Interceptor(); + }); + + it('should be defined', () => { + expect(interceptor).toBeDefined(); + }); +}); +`; +} + +function toPascalCase(str: string): string { + return str + .split('-') + .map((part) => part.charAt(0).toUpperCase() + part.slice(1)) + .join(''); +} diff --git a/src/templates/generate/module.template.ts b/src/templates/generate/module.template.ts new file mode 100644 index 0000000..05cd274 --- /dev/null +++ b/src/templates/generate/module.template.ts @@ -0,0 +1,36 @@ +export function createModuleTemplate(name: string): string { + const className = toPascalCase(name); + return `import { Module } from '@nestjs/common'; + +@Module({}) +export class ${className}Module {} +`; +} + +export function createModuleSpecTemplate(name: string): string { + const className = toPascalCase(name); + return `import { Test, TestingModule } from '@nestjs/testing'; +import { ${className}Module } from './${name}.module'; + +describe('${className}Module', () => { + let module: TestingModule; + + beforeEach(async () => { + module = await Test.createTestingModule({ + imports: [${className}Module], + }).compile(); + }); + + it('should be defined', () => { + expect(module).toBeDefined(); + }); +}); +`; +} + +function toPascalCase(str: string): string { + return str + .split('-') + .map((part) => part.charAt(0).toUpperCase() + part.slice(1)) + .join(''); +} diff --git a/src/templates/generate/pipe.template.ts b/src/templates/generate/pipe.template.ts new file mode 100644 index 0000000..761b16b --- /dev/null +++ b/src/templates/generate/pipe.template.ts @@ -0,0 +1,43 @@ +export function createPipeTemplate(name: string): string { + const className = toPascalCase(name); + return `import { ArgumentMetadata, Injectable, PipeTransform } from '@nestjs/common'; + +@Injectable() +export class ${className}Pipe implements PipeTransform { + transform(value: any, metadata: ArgumentMetadata) { + return value; + } +} +`; +} + +export function createPipeSpecTemplate(name: string): string { + const className = toPascalCase(name); + return `import { ${className}Pipe } from './${name}.pipe'; + +describe('${className}Pipe', () => { + let pipe: ${className}Pipe; + + beforeEach(() => { + pipe = new ${className}Pipe(); + }); + + it('should be defined', () => { + expect(pipe).toBeDefined(); + }); + + it('should return the value unchanged', () => { + const value = 'test'; + const metadata = { type: 'body', metatype: String, data: '' } as any; + expect(pipe.transform(value, metadata)).toBe(value); + }); +}); +`; +} + +function toPascalCase(str: string): string { + return str + .split('-') + .map((part) => part.charAt(0).toUpperCase() + part.slice(1)) + .join(''); +} diff --git a/src/templates/generate/service.template.ts b/src/templates/generate/service.template.ts new file mode 100644 index 0000000..f9a6e1b --- /dev/null +++ b/src/templates/generate/service.template.ts @@ -0,0 +1,38 @@ +export function createServiceTemplate(name: string): string { + const className = toPascalCase(name); + return `import { Injectable } from '@nestjs/common'; + +@Injectable() +export class ${className}Service {} +`; +} + +export function createServiceSpecTemplate(name: string): string { + const className = toPascalCase(name); + return `import { Test, TestingModule } from '@nestjs/testing'; +import { ${className}Service } from './${name}.service'; + +describe('${className}Service', () => { + let service: ${className}Service; + + beforeEach(async () => { + const module: TestingModule = await Test.createTestingModule({ + providers: [${className}Service], + }).compile(); + + service = module.get<${className}Service>(${className}Service); + }); + + it('should be defined', () => { + expect(service).toBeDefined(); + }); +}); +`; +} + +function toPascalCase(str: string): string { + return str + .split('-') + .map((part) => part.charAt(0).toUpperCase() + part.slice(1)) + .join(''); +} diff --git a/src/types/project.types.ts b/src/types/project.types.ts index 54e9f73..029f6ee 100644 --- a/src/types/project.types.ts +++ b/src/types/project.types.ts @@ -1,4 +1,10 @@ -import { PackageManager, Database, ORM } from '../constants/enums'; +import { + PackageManager, + Database, + ORM, + Schematic, + AuthFeature, +} from '../constants/enums'; export interface ProjectAnswers { packageManager: PackageManager; @@ -7,6 +13,8 @@ export interface ProjectAnswers { useDocker: boolean; database?: Database; orm?: ORM; + useAuth?: boolean; + authFeatures?: AuthFeature[]; } export interface NewCommandOptions { @@ -19,3 +27,20 @@ export interface ProjectConfig { path: string; answers: ProjectAnswers; } + +export interface GenerateCommandOptions { + skipSpec: boolean; + flat: boolean; + dryRun: boolean; +} + +export interface GenerateConfig { + schematic: Schematic; + name: string; + options: GenerateCommandOptions; +} + +export interface AuthGenerateConfig { + features: AuthFeature[]; + options: GenerateCommandOptions; +}