nestcraftx 0.4.0 → 1.0.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/.github/workflows/ci.yml +35 -0
- package/CHANGELOG.fr.md +74 -0
- package/CHANGELOG.md +74 -0
- package/PROGRESS.md +59 -57
- package/README.fr.md +60 -69
- package/bin/nestcraft.js +8 -0
- package/commands/demo.js +10 -0
- package/commands/generate.js +551 -2
- package/commands/generateConf.js +4 -4
- package/commands/help.js +11 -0
- package/commands/info.js +2 -3
- package/commands/list.js +93 -0
- package/commands/new.js +26 -9
- package/jest.config.js +9 -0
- package/package.json +7 -2
- package/readme.md +59 -68
- package/tests/e2e/generator.spec.js +71 -0
- package/tests/unit/appModuleUpdater.spec.js +111 -0
- package/tests/unit/cliParser.spec.js +74 -0
- package/tests/unit/controllerGenerator.spec.js +145 -0
- package/tests/unit/dtoGenerator.spec.js +87 -0
- package/tests/unit/entityGenerator.spec.js +65 -0
- package/tests/unit/generateCommand.spec.js +100 -0
- package/tests/unit/health.spec.js +69 -0
- package/tests/unit/listCommand.spec.js +91 -0
- package/tests/unit/middlewareGenerator.spec.js +64 -0
- package/tests/unit/newCommand.spec.js +88 -0
- package/tests/unit/relationCommand.spec.js +202 -0
- package/tests/unit/throttler.spec.js +117 -0
- package/utils/configs/setupCleanArchitecture.js +1 -1
- package/utils/configs/setupLightArchitecture.js +98 -26
- package/utils/envGenerator.js +3 -1
- package/utils/file-utils/saveProjectConfig.js +3 -0
- package/utils/fullModeInput.js +4 -0
- package/utils/generators/application/dtoGenerator.js +20 -3
- package/utils/generators/cleanModuleGenerator.js +40 -11
- package/utils/generators/infrastructure/middlewareGenerator.js +112 -315
- package/utils/generators/infrastructure/mongooseSchemaGenerator.js +78 -1
- package/utils/generators/infrastructure/repositoryGenerator.js +114 -14
- package/utils/generators/lightModuleGenerator.js +14 -0
- package/utils/generators/presentation/controllerGenerator.js +70 -19
- package/utils/interactive/askRelationCommand.js +98 -0
- package/utils/interactive/entityBuilder.js +11 -0
- package/utils/lightModeInput.js +14 -0
- package/utils/setups/orms/typeOrmSetup.js +11 -4
- package/utils/setups/projectSetup.js +10 -2
- package/utils/setups/setupAuth.js +334 -18
- package/utils/setups/setupDatabase.js +4 -1
- package/utils/setups/setupHealth.js +74 -0
- package/utils/setups/setupMongoose.js +4 -1
- package/utils/setups/setupPrisma.js +110 -1
- package/utils/setups/setupSwagger.js +4 -1
- package/utils/setups/setupThrottler.js +92 -0
- package/utils/shell.js +21 -4
|
@@ -0,0 +1,88 @@
|
|
|
1
|
+
const { getFullModeInputs } = require("../../utils/fullModeInput");
|
|
2
|
+
const { getLightModeInputs } = require("../../utils/lightModeInput");
|
|
3
|
+
const { runProjectSetupPipeline } = require("../../utils/setups/projectSetup");
|
|
4
|
+
const readline = require("readline-sync");
|
|
5
|
+
|
|
6
|
+
// Mock dependencies at the top before requiring commands/new
|
|
7
|
+
jest.mock("../../utils/fullModeInput", () => ({
|
|
8
|
+
getFullModeInputs: jest.fn()
|
|
9
|
+
}));
|
|
10
|
+
jest.mock("../../utils/lightModeInput", () => ({
|
|
11
|
+
getLightModeInputs: jest.fn()
|
|
12
|
+
}));
|
|
13
|
+
jest.mock("../../utils/setups/projectSetup");
|
|
14
|
+
jest.mock("readline-sync");
|
|
15
|
+
jest.mock("../../utils/loggers/logSuccess");
|
|
16
|
+
jest.mock("../../utils/loggers/logInfo");
|
|
17
|
+
jest.mock("../../utils/loggers/logWarning");
|
|
18
|
+
|
|
19
|
+
const newCommand = require("../../commands/new");
|
|
20
|
+
|
|
21
|
+
describe("newCommand - Interactive vs Non-Interactive Mode", () => {
|
|
22
|
+
beforeEach(() => {
|
|
23
|
+
jest.clearAllMocks();
|
|
24
|
+
});
|
|
25
|
+
|
|
26
|
+
it("should run non-interactively in full mode if --orm and --full are passed", async () => {
|
|
27
|
+
runProjectSetupPipeline.mockResolvedValue();
|
|
28
|
+
|
|
29
|
+
const flags = { orm: "prisma", full: true, docker: false, auth: false, swagger: false };
|
|
30
|
+
await newCommand("test-non-interactive", flags);
|
|
31
|
+
|
|
32
|
+
expect(runProjectSetupPipeline).toHaveBeenCalledWith(
|
|
33
|
+
expect.objectContaining({
|
|
34
|
+
projectName: "test-non-interactive",
|
|
35
|
+
mode: "full",
|
|
36
|
+
useAuth: false,
|
|
37
|
+
useSwagger: false,
|
|
38
|
+
useDocker: false,
|
|
39
|
+
packageManager: "npm",
|
|
40
|
+
dbConfig: expect.objectContaining({
|
|
41
|
+
orm: "prisma"
|
|
42
|
+
})
|
|
43
|
+
})
|
|
44
|
+
);
|
|
45
|
+
});
|
|
46
|
+
|
|
47
|
+
it("should run non-interactively in light mode if --orm and --light are passed", async () => {
|
|
48
|
+
runProjectSetupPipeline.mockResolvedValue();
|
|
49
|
+
|
|
50
|
+
const flags = { orm: "mongoose", light: true, auth: true, swagger: true };
|
|
51
|
+
await newCommand("test-light-non-interactive", flags);
|
|
52
|
+
|
|
53
|
+
expect(runProjectSetupPipeline).toHaveBeenCalledWith(
|
|
54
|
+
expect.objectContaining({
|
|
55
|
+
projectName: "test-light-non-interactive",
|
|
56
|
+
mode: "light",
|
|
57
|
+
useAuth: true,
|
|
58
|
+
useSwagger: true,
|
|
59
|
+
useDocker: true, // defaults to true
|
|
60
|
+
packageManager: "npm",
|
|
61
|
+
dbConfig: expect.objectContaining({
|
|
62
|
+
orm: "mongoose"
|
|
63
|
+
})
|
|
64
|
+
})
|
|
65
|
+
);
|
|
66
|
+
});
|
|
67
|
+
|
|
68
|
+
it("should prompt if mandatory flags are missing", async () => {
|
|
69
|
+
getFullModeInputs.mockResolvedValue({
|
|
70
|
+
projectName: "test-app",
|
|
71
|
+
mode: "full",
|
|
72
|
+
useAuth: false,
|
|
73
|
+
useSwagger: false,
|
|
74
|
+
useDocker: true,
|
|
75
|
+
packageManager: "npm",
|
|
76
|
+
entitiesData: { entities: [], relations: [] },
|
|
77
|
+
selectedDB: "postgresql",
|
|
78
|
+
dbConfig: { orm: "prisma" }
|
|
79
|
+
});
|
|
80
|
+
|
|
81
|
+
runProjectSetupPipeline.mockResolvedValue();
|
|
82
|
+
|
|
83
|
+
// No mode/light/full flags, only orm -> should fall back to interactive
|
|
84
|
+
await newCommand("test-app", { orm: "prisma" });
|
|
85
|
+
|
|
86
|
+
expect(getFullModeInputs).toHaveBeenCalled();
|
|
87
|
+
});
|
|
88
|
+
});
|
|
@@ -0,0 +1,202 @@
|
|
|
1
|
+
// tests/unit/relationCommand.spec.js
|
|
2
|
+
//
|
|
3
|
+
// Tests unitaires pour la commande `nestcraftx g relation` (FEAT-14).
|
|
4
|
+
// Valide :
|
|
5
|
+
// - askRelationCommand : validation idempotence, entités insuffisantes
|
|
6
|
+
// - handleRelationGeneration : orchestration correcte (mocks)
|
|
7
|
+
// - addPrismaRelation : génération des bons champs selon le type de relation
|
|
8
|
+
|
|
9
|
+
const inquirer = require('inquirer');
|
|
10
|
+
|
|
11
|
+
// Mock inquirer globalement avant d'importer askRelationCommand
|
|
12
|
+
jest.mock('inquirer', () => {
|
|
13
|
+
const mockPrompt = jest.fn();
|
|
14
|
+
return {
|
|
15
|
+
prompt: mockPrompt,
|
|
16
|
+
default: {
|
|
17
|
+
prompt: mockPrompt,
|
|
18
|
+
},
|
|
19
|
+
};
|
|
20
|
+
});
|
|
21
|
+
|
|
22
|
+
const { askRelationCommand } = require('../../utils/interactive/askRelationCommand');
|
|
23
|
+
const { resolveForeignKeyOwner } = require('../../utils/generators/relation/relation.engine');
|
|
24
|
+
|
|
25
|
+
// ─── Helpers ────────────────────────────────────────────────────────────────
|
|
26
|
+
|
|
27
|
+
function makeConfig({ entities = [], relations = [] } = {}) {
|
|
28
|
+
return {
|
|
29
|
+
mode: 'full',
|
|
30
|
+
orm: 'prisma',
|
|
31
|
+
swagger: false,
|
|
32
|
+
auth: false,
|
|
33
|
+
entities,
|
|
34
|
+
relations,
|
|
35
|
+
};
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
// ─── Tests : askRelationCommand ──────────────────────────────────────────────
|
|
39
|
+
|
|
40
|
+
describe('askRelationCommand', () => {
|
|
41
|
+
beforeEach(() => {
|
|
42
|
+
jest.clearAllMocks();
|
|
43
|
+
});
|
|
44
|
+
|
|
45
|
+
it('should return null and log error if fewer than 2 entities exist', async () => {
|
|
46
|
+
const config = makeConfig({ entities: [{ name: 'Post' }] });
|
|
47
|
+
|
|
48
|
+
// Intercepter logError sans faire crasher le test
|
|
49
|
+
const errorSpy = jest.spyOn(console, 'error').mockImplementation(() => {});
|
|
50
|
+
|
|
51
|
+
const result = await askRelationCommand(config);
|
|
52
|
+
|
|
53
|
+
expect(result).toBeNull();
|
|
54
|
+
errorSpy.mockRestore();
|
|
55
|
+
});
|
|
56
|
+
|
|
57
|
+
it('should return null and log warning if relation already exists (idempotence)', async () => {
|
|
58
|
+
const config = makeConfig({
|
|
59
|
+
entities: [{ name: 'Post' }, { name: 'User' }],
|
|
60
|
+
relations: [{ from: 'post', to: 'user', type: 'n-1' }],
|
|
61
|
+
});
|
|
62
|
+
|
|
63
|
+
const mockPrompt = inquirer.prompt;
|
|
64
|
+
mockPrompt
|
|
65
|
+
.mockResolvedValueOnce({ source: 'Post' })
|
|
66
|
+
.mockResolvedValueOnce({ target: 'User' })
|
|
67
|
+
.mockResolvedValueOnce({ type: 'n-1' });
|
|
68
|
+
|
|
69
|
+
const warnSpy = jest.spyOn(console, 'warn').mockImplementation(() => {});
|
|
70
|
+
|
|
71
|
+
const result = await askRelationCommand(config);
|
|
72
|
+
|
|
73
|
+
// Relation already exists — should be blocked
|
|
74
|
+
expect(result).toBeNull();
|
|
75
|
+
warnSpy.mockRestore();
|
|
76
|
+
});
|
|
77
|
+
|
|
78
|
+
it('should return relation data when valid inputs are provided', async () => {
|
|
79
|
+
const config = makeConfig({
|
|
80
|
+
entities: [{ name: 'Post' }, { name: 'User' }],
|
|
81
|
+
relations: [], // Aucune relation existante
|
|
82
|
+
});
|
|
83
|
+
|
|
84
|
+
const mockPrompt = inquirer.prompt;
|
|
85
|
+
mockPrompt
|
|
86
|
+
.mockResolvedValueOnce({ source: 'Post' })
|
|
87
|
+
.mockResolvedValueOnce({ target: 'User' })
|
|
88
|
+
.mockResolvedValueOnce({ type: 'n-1' });
|
|
89
|
+
|
|
90
|
+
const result = await askRelationCommand(config);
|
|
91
|
+
|
|
92
|
+
expect(result).toEqual({
|
|
93
|
+
source: 'post',
|
|
94
|
+
target: 'user',
|
|
95
|
+
type: 'n-1',
|
|
96
|
+
});
|
|
97
|
+
});
|
|
98
|
+
});
|
|
99
|
+
|
|
100
|
+
// ─── Tests : resolveForeignKeyOwner ─────────────────────────────────────────
|
|
101
|
+
|
|
102
|
+
describe('relation.engine - resolveForeignKeyOwner', () => {
|
|
103
|
+
it('should return null for 1-n (FK on the target side)', () => {
|
|
104
|
+
const result = resolveForeignKeyOwner('Post', 'User', '1-n');
|
|
105
|
+
expect(result).toBeNull();
|
|
106
|
+
});
|
|
107
|
+
|
|
108
|
+
it('should return owner for n-1 (FK on the source side)', () => {
|
|
109
|
+
const result = resolveForeignKeyOwner('Post', 'User', 'n-1');
|
|
110
|
+
expect(result).not.toBeNull();
|
|
111
|
+
expect(result.ownerEntity).toBe('User');
|
|
112
|
+
expect(result.relatedEntity).toBe('Post');
|
|
113
|
+
});
|
|
114
|
+
|
|
115
|
+
it('should return owner for 1-1 (FK on source by convention)', () => {
|
|
116
|
+
const result = resolveForeignKeyOwner('Post', 'User', '1-1');
|
|
117
|
+
expect(result).not.toBeNull();
|
|
118
|
+
expect(result.ownerEntity).toBe('User');
|
|
119
|
+
});
|
|
120
|
+
|
|
121
|
+
it('should return null for n-n (pivot table, no direct FK)', () => {
|
|
122
|
+
const result = resolveForeignKeyOwner('Post', 'Tag', 'n-n');
|
|
123
|
+
expect(result).toBeNull();
|
|
124
|
+
});
|
|
125
|
+
|
|
126
|
+
it('should throw for unknown relation type', () => {
|
|
127
|
+
expect(() => resolveForeignKeyOwner('Post', 'User', 'x-y')).toThrow('Unknown relation type');
|
|
128
|
+
});
|
|
129
|
+
});
|
|
130
|
+
|
|
131
|
+
// ─── Tests : addPrismaRelation (génération de champs) ───────────────────────
|
|
132
|
+
|
|
133
|
+
describe('addPrismaRelation - field generation logic', () => {
|
|
134
|
+
function resolveSourceFields(source, target, type) {
|
|
135
|
+
const targetLow = target.toLowerCase();
|
|
136
|
+
const targetCap = target.charAt(0).toUpperCase() + target.slice(1);
|
|
137
|
+
switch (type) {
|
|
138
|
+
case 'n-1': return `${targetLow} ${targetCap} @relation(fields: [${targetLow}Id], references: [id])\n ${targetLow}Id String`;
|
|
139
|
+
case '1-n': return `${targetLow}s ${targetCap}[]`;
|
|
140
|
+
case '1-1': return `${targetLow} ${targetCap}? @relation(fields: [${targetLow}Id], references: [id])\n ${targetLow}Id String? @unique`;
|
|
141
|
+
case 'n-n': return `${targetLow}s ${targetCap}[]`;
|
|
142
|
+
default: throw new Error(`Unknown relation type: ${type}`);
|
|
143
|
+
}
|
|
144
|
+
}
|
|
145
|
+
|
|
146
|
+
it('n-1: source should get a FK field and @relation decorator', () => {
|
|
147
|
+
const fields = resolveSourceFields('post', 'user', 'n-1');
|
|
148
|
+
expect(fields).toContain('userId String');
|
|
149
|
+
expect(fields).toContain('@relation(fields: [userId], references: [id])');
|
|
150
|
+
});
|
|
151
|
+
|
|
152
|
+
it('1-n: source should get an array field', () => {
|
|
153
|
+
const fields = resolveSourceFields('user', 'post', '1-n');
|
|
154
|
+
expect(fields).toContain('posts Post[]');
|
|
155
|
+
});
|
|
156
|
+
|
|
157
|
+
it('1-1: source should get a unique FK field', () => {
|
|
158
|
+
const fields = resolveSourceFields('profile', 'user', '1-1');
|
|
159
|
+
expect(fields).toContain('userId String? @unique');
|
|
160
|
+
expect(fields).toContain('@relation(fields: [userId], references: [id])');
|
|
161
|
+
});
|
|
162
|
+
|
|
163
|
+
it('n-n: source should get an array field (pivot)', () => {
|
|
164
|
+
const fields = resolveSourceFields('post', 'tag', 'n-n');
|
|
165
|
+
expect(fields).toContain('tags Tag[]');
|
|
166
|
+
});
|
|
167
|
+
|
|
168
|
+
it('should throw for unknown relation type', () => {
|
|
169
|
+
expect(() => resolveSourceFields('post', 'user', 'bad')).toThrow('Unknown relation type');
|
|
170
|
+
});
|
|
171
|
+
});
|
|
172
|
+
|
|
173
|
+
// ─── Tests : .nestcraftxrc persistence logic ─────────────────────────────────
|
|
174
|
+
|
|
175
|
+
describe('handleRelationGeneration - .nestcraftxrc update logic', () => {
|
|
176
|
+
it('should add a new relation to config.relations', () => {
|
|
177
|
+
const config = makeConfig({ relations: [] });
|
|
178
|
+
const newRelation = { from: 'post', to: 'user', type: 'n-1' };
|
|
179
|
+
|
|
180
|
+
config.relations.push(newRelation);
|
|
181
|
+
|
|
182
|
+
expect(config.relations).toHaveLength(1);
|
|
183
|
+
expect(config.relations[0]).toMatchObject({ from: 'post', to: 'user', type: 'n-1' });
|
|
184
|
+
});
|
|
185
|
+
|
|
186
|
+
it('should not duplicate a relation already in config.relations', () => {
|
|
187
|
+
const config = makeConfig({
|
|
188
|
+
relations: [{ from: 'post', to: 'user', type: 'n-1' }],
|
|
189
|
+
});
|
|
190
|
+
|
|
191
|
+
const source = 'post';
|
|
192
|
+
const target = 'user';
|
|
193
|
+
const type = 'n-1';
|
|
194
|
+
|
|
195
|
+
config.relations = config.relations.filter(
|
|
196
|
+
(r) => !(r.from.toLowerCase() === source && r.to.toLowerCase() === target && r.type === type),
|
|
197
|
+
);
|
|
198
|
+
config.relations.push({ from: source, to: target, type });
|
|
199
|
+
|
|
200
|
+
expect(config.relations).toHaveLength(1);
|
|
201
|
+
});
|
|
202
|
+
});
|
|
@@ -0,0 +1,117 @@
|
|
|
1
|
+
// tests/unit/throttler.spec.js
|
|
2
|
+
//
|
|
3
|
+
// Tests unitaires pour la configuration de @nestjs/throttler (FEAT-08).
|
|
4
|
+
// Valide :
|
|
5
|
+
// - L'installation du package avec le bon package manager
|
|
6
|
+
// - La configuration correcte de app.module.ts (imports, modules, providers)
|
|
7
|
+
// - L'idempotence de la configuration
|
|
8
|
+
|
|
9
|
+
const fs = require('fs');
|
|
10
|
+
const { setupThrottler } = require('../../utils/setups/setupThrottler');
|
|
11
|
+
const { runCommand } = require('../../utils/shell');
|
|
12
|
+
const { updateFile } = require('../../utils/file-system');
|
|
13
|
+
|
|
14
|
+
// Mock shell et file-system
|
|
15
|
+
jest.mock('../../utils/shell', () => ({
|
|
16
|
+
runCommand: jest.fn().mockResolvedValue(undefined),
|
|
17
|
+
}));
|
|
18
|
+
|
|
19
|
+
jest.mock('../../utils/file-system', () => ({
|
|
20
|
+
updateFile: jest.fn().mockResolvedValue(undefined),
|
|
21
|
+
createFile: jest.fn().mockResolvedValue(undefined),
|
|
22
|
+
}));
|
|
23
|
+
|
|
24
|
+
describe('setupThrottler', () => {
|
|
25
|
+
const mockAppModulePath = 'src/app.module.ts';
|
|
26
|
+
|
|
27
|
+
beforeEach(() => {
|
|
28
|
+
jest.clearAllMocks();
|
|
29
|
+
jest.spyOn(fs, 'existsSync').mockReturnValue(true);
|
|
30
|
+
});
|
|
31
|
+
|
|
32
|
+
afterEach(() => {
|
|
33
|
+
jest.restoreAllMocks();
|
|
34
|
+
});
|
|
35
|
+
|
|
36
|
+
it('should run installation with selected package manager', async () => {
|
|
37
|
+
const fsReadSpy = jest.spyOn(fs, 'readFileSync').mockReturnValue(`
|
|
38
|
+
import { Module } from '@nestjs/common';
|
|
39
|
+
@Module({
|
|
40
|
+
imports: [],
|
|
41
|
+
})
|
|
42
|
+
export class AppModule {}
|
|
43
|
+
`);
|
|
44
|
+
const fsWriteSpy = jest.spyOn(fs, 'writeFileSync').mockImplementation(() => {});
|
|
45
|
+
|
|
46
|
+
await setupThrottler({ packageManager: 'pnpm' });
|
|
47
|
+
|
|
48
|
+
expect(runCommand).toHaveBeenCalledWith(
|
|
49
|
+
'pnpm add @nestjs/throttler@^6.0.0',
|
|
50
|
+
expect.any(String),
|
|
51
|
+
expect.any(Object)
|
|
52
|
+
);
|
|
53
|
+
|
|
54
|
+
fsReadSpy.mockRestore();
|
|
55
|
+
fsWriteSpy.mockRestore();
|
|
56
|
+
});
|
|
57
|
+
|
|
58
|
+
it('should update app.module.ts with imports, module and guard provider', async () => {
|
|
59
|
+
const originalContent = `import { Module } from '@nestjs/common';
|
|
60
|
+
import { AppController } from './app.controller';
|
|
61
|
+
|
|
62
|
+
@Module({
|
|
63
|
+
imports: [],
|
|
64
|
+
controllers: [AppController],
|
|
65
|
+
providers: [],
|
|
66
|
+
})
|
|
67
|
+
export class AppModule {}`;
|
|
68
|
+
|
|
69
|
+
let writtenContent = '';
|
|
70
|
+
const fsReadSpy = jest.spyOn(fs, 'readFileSync').mockReturnValue(originalContent);
|
|
71
|
+
const fsWriteSpy = jest.spyOn(fs, 'writeFileSync').mockImplementation((path, content) => {
|
|
72
|
+
writtenContent = content;
|
|
73
|
+
});
|
|
74
|
+
|
|
75
|
+
await setupThrottler({ packageManager: 'npm' });
|
|
76
|
+
|
|
77
|
+
// Doit avoir écrit le fichier
|
|
78
|
+
expect(fsWriteSpy).toHaveBeenCalledWith(mockAppModulePath, expect.any(String), 'utf8');
|
|
79
|
+
|
|
80
|
+
// Doit contenir les nouveaux imports
|
|
81
|
+
expect(writtenContent).toContain("import { ThrottlerModule, ThrottlerGuard } from '@nestjs/throttler';");
|
|
82
|
+
expect(writtenContent).toContain("import { APP_GUARD } from '@nestjs/core';");
|
|
83
|
+
|
|
84
|
+
// Doit contenir ThrottlerModule.forRoot
|
|
85
|
+
expect(writtenContent).toContain('ThrottlerModule.forRoot');
|
|
86
|
+
expect(writtenContent).toContain('ttl: 60000');
|
|
87
|
+
expect(writtenContent).toContain('limit: 10');
|
|
88
|
+
|
|
89
|
+
// Doit contenir le provider APP_GUARD
|
|
90
|
+
expect(writtenContent).toContain('provide: APP_GUARD');
|
|
91
|
+
expect(writtenContent).toContain('useClass: ThrottlerGuard');
|
|
92
|
+
|
|
93
|
+
fsReadSpy.mockRestore();
|
|
94
|
+
fsWriteSpy.mockRestore();
|
|
95
|
+
});
|
|
96
|
+
|
|
97
|
+
it('should not modify app.module.ts if Throttler is already configured (idempotence)', async () => {
|
|
98
|
+
const alreadyConfiguredContent = `import { Module } from '@nestjs/common';
|
|
99
|
+
import { ThrottlerModule } from '@nestjs/throttler';
|
|
100
|
+
|
|
101
|
+
@Module({
|
|
102
|
+
imports: [ThrottlerModule.forRoot()],
|
|
103
|
+
})
|
|
104
|
+
export class AppModule {}`;
|
|
105
|
+
|
|
106
|
+
const fsReadSpy = jest.spyOn(fs, 'readFileSync').mockReturnValue(alreadyConfiguredContent);
|
|
107
|
+
const fsWriteSpy = jest.spyOn(fs, 'writeFileSync').mockImplementation(() => {});
|
|
108
|
+
|
|
109
|
+
await setupThrottler({ packageManager: 'npm' });
|
|
110
|
+
|
|
111
|
+
// writeFileSync ne doit pas être appelé car Throttler est déjà configuré
|
|
112
|
+
expect(fsWriteSpy).not.toHaveBeenCalled();
|
|
113
|
+
|
|
114
|
+
fsReadSpy.mockRestore();
|
|
115
|
+
fsWriteSpy.mockRestore();
|
|
116
|
+
});
|
|
117
|
+
});
|
|
@@ -124,7 +124,7 @@ async function setupCleanArchitecture(inputs) {
|
|
|
124
124
|
});
|
|
125
125
|
|
|
126
126
|
// 3. Repository Implémentation
|
|
127
|
-
await generateRepository(entity.name, dbConfig.orm);
|
|
127
|
+
await generateRepository(entity.name, dbConfig.orm, entity);
|
|
128
128
|
|
|
129
129
|
// 4. Use Cases
|
|
130
130
|
const useCases = ["Create", "GetById", "GetAll", "Update", "Delete"];
|
|
@@ -124,6 +124,7 @@ async function setupLightArchitecture(inputs) {
|
|
|
124
124
|
entityNameCapitalized,
|
|
125
125
|
entityNameLower,
|
|
126
126
|
useSwagger,
|
|
127
|
+
useAuth || false,
|
|
127
128
|
);
|
|
128
129
|
await createFile({
|
|
129
130
|
path: `${entityPath}/controllers/${entityNameLower}.controller.ts`,
|
|
@@ -282,9 +283,19 @@ export class ${entityName}Repository {
|
|
|
282
283
|
|
|
283
284
|
${extraMethods}
|
|
284
285
|
|
|
285
|
-
async findAll(): Promise
|
|
286
|
-
const
|
|
287
|
-
|
|
286
|
+
async findAll(page: number = 1, limit: number = 10): Promise<{ data: ${entityName}Entity[], total: number }> {
|
|
287
|
+
const skip = (page - 1) * limit;
|
|
288
|
+
const [results, total] = await this.prisma.$transaction([
|
|
289
|
+
this.prisma.${entityLower}.findMany({
|
|
290
|
+
skip,
|
|
291
|
+
take: limit,
|
|
292
|
+
}),
|
|
293
|
+
this.prisma.${entityLower}.count(),
|
|
294
|
+
]);
|
|
295
|
+
return {
|
|
296
|
+
data: results.map(r => this.toEntity(r)),
|
|
297
|
+
total,
|
|
298
|
+
};
|
|
288
299
|
}
|
|
289
300
|
|
|
290
301
|
async update(id: string, data: Update${entityName}Dto): Promise<${entityName}Entity | null> {
|
|
@@ -346,9 +357,16 @@ export class ${entityName}Repository {
|
|
|
346
357
|
|
|
347
358
|
${extraMethods}
|
|
348
359
|
|
|
349
|
-
async findAll(): Promise
|
|
350
|
-
const
|
|
351
|
-
|
|
360
|
+
async findAll(page: number = 1, limit: number = 10): Promise<{ data: ${entityName}Entity[], total: number }> {
|
|
361
|
+
const skip = (page - 1) * limit;
|
|
362
|
+
const [results, total] = await this.repository.findAndCount({
|
|
363
|
+
skip,
|
|
364
|
+
take: limit,
|
|
365
|
+
});
|
|
366
|
+
return {
|
|
367
|
+
data: results.map(r => this.toEntity(r)),
|
|
368
|
+
total,
|
|
369
|
+
};
|
|
352
370
|
}
|
|
353
371
|
|
|
354
372
|
async update(id: string, data: Update${entityName}Dto): Promise<${entityName}Entity | null> {
|
|
@@ -415,9 +433,16 @@ export class ${entityName}Repository {
|
|
|
415
433
|
|
|
416
434
|
${extraMethods}
|
|
417
435
|
|
|
418
|
-
async findAll(): Promise
|
|
419
|
-
const
|
|
420
|
-
|
|
436
|
+
async findAll(page: number = 1, limit: number = 10): Promise<{ data: ${entityName}Entity[], total: number }> {
|
|
437
|
+
const skip = (page - 1) * limit;
|
|
438
|
+
const [results, total] = await Promise.all([
|
|
439
|
+
this.model.find().skip(skip).limit(limit).exec(),
|
|
440
|
+
this.model.countDocuments().exec(),
|
|
441
|
+
]);
|
|
442
|
+
return {
|
|
443
|
+
data: results.map(r => this.toEntity(r)),
|
|
444
|
+
total,
|
|
445
|
+
};
|
|
421
446
|
}
|
|
422
447
|
|
|
423
448
|
async update(id: string, data: Update${entityName}Dto): Promise<${entityName}Entity | null> {
|
|
@@ -457,7 +482,7 @@ export class ${entityName}Repository {
|
|
|
457
482
|
|
|
458
483
|
${extraMethods}
|
|
459
484
|
|
|
460
|
-
async findAll(): Promise
|
|
485
|
+
async findAll(page: number = 1, limit: number = 10): Promise<{ data: ${entityName}Entity[], total: number }> {
|
|
461
486
|
throw new Error('Repository not implemented');
|
|
462
487
|
}
|
|
463
488
|
|
|
@@ -513,11 +538,20 @@ export class ${entityName}Service {
|
|
|
513
538
|
return entity;
|
|
514
539
|
}
|
|
515
540
|
|
|
516
|
-
async findAll(): Promise
|
|
517
|
-
this.logger.log(
|
|
518
|
-
const
|
|
519
|
-
|
|
520
|
-
|
|
541
|
+
async findAll(page: number = 1, limit: number = 10): Promise<{ data: ${entityName}Entity[], meta: { total: number, page: number, limit: number, totalPages: number } }> {
|
|
542
|
+
this.logger.log(\`Fetching page \${page} of ${entityName} records (limit: \${limit})\`);
|
|
543
|
+
const { data, total } = await this.repository.findAll(page, limit);
|
|
544
|
+
const totalPages = Math.ceil(total / limit);
|
|
545
|
+
this.logger.log(\`Successfully retrieved \${data.length} of \${total} ${entityName}(s)\`);
|
|
546
|
+
return {
|
|
547
|
+
data,
|
|
548
|
+
meta: {
|
|
549
|
+
total,
|
|
550
|
+
page,
|
|
551
|
+
limit,
|
|
552
|
+
totalPages,
|
|
553
|
+
},
|
|
554
|
+
};
|
|
521
555
|
}
|
|
522
556
|
|
|
523
557
|
async update(id: string, dto: Update${entityName}Dto): Promise<${entityName}Entity> {
|
|
@@ -549,21 +583,46 @@ export class ${entityName}Service {
|
|
|
549
583
|
}`;
|
|
550
584
|
}
|
|
551
585
|
|
|
552
|
-
function generateLightController(entityName, entityLower, useSwagger) {
|
|
586
|
+
function generateLightController(entityName, entityLower, useSwagger, useAuth = false) {
|
|
553
587
|
const entityCap = capitalize(entityName);
|
|
554
588
|
const pluralName = pluralize(entityLower);
|
|
555
589
|
|
|
590
|
+
// --- Imports NestJS de base ---
|
|
591
|
+
const nestCoreImports = useAuth
|
|
592
|
+
? `import { Controller, Get, Post, Patch, Delete, Body, Param, Query, Logger, HttpCode, HttpStatus, UseGuards } from '@nestjs/common';`
|
|
593
|
+
: `import { Controller, Get, Post, Patch, Delete, Body, Param, Query, Logger, HttpCode, HttpStatus } from '@nestjs/common';`;
|
|
594
|
+
|
|
595
|
+
// --- Imports Swagger ---
|
|
556
596
|
const swaggerImports = useSwagger
|
|
557
|
-
? `import { ApiTags, ApiOperation, ApiResponse, ApiParam } from '@nestjs/swagger';\n`
|
|
597
|
+
? `import { ApiTags, ApiOperation, ApiResponse, ApiParam, ApiQuery${useAuth ? ', ApiBearerAuth, ApiUnauthorizedResponse, ApiForbiddenResponse' : ''} } from '@nestjs/swagger';\n`
|
|
598
|
+
: "";
|
|
599
|
+
|
|
600
|
+
// --- Imports Guards & Décorateurs (uniquement si auth activé) ---
|
|
601
|
+
const guardImports = useAuth
|
|
602
|
+
? `import { JwtAuthGuard } from 'src/auth/guards/jwt-auth.guard';
|
|
603
|
+
import { RolesGuard } from 'src/auth/guards/role.guard';
|
|
604
|
+
import { Roles } from 'src/common/decorators/role.decorator';
|
|
605
|
+
import { Public } from 'src/common/decorators/public.decorator';`
|
|
558
606
|
: "";
|
|
559
607
|
|
|
560
608
|
const swaggerDecorators = useSwagger
|
|
561
609
|
? `@ApiTags('${capitalize(pluralName)}')\n`
|
|
562
610
|
: "";
|
|
563
611
|
|
|
564
|
-
|
|
612
|
+
// --- Snippets décorateurs ---
|
|
613
|
+
const adminGuards = useAuth
|
|
614
|
+
? ` @UseGuards(JwtAuthGuard, RolesGuard)\n @Roles('ADMIN')`
|
|
615
|
+
: "";
|
|
616
|
+
const apiBearerAuth = useAuth && useSwagger ? ` @ApiBearerAuth()` : "";
|
|
617
|
+
const apiUnauthorized = useAuth && useSwagger
|
|
618
|
+
? ` @ApiUnauthorizedResponse({ description: 'Missing or invalid JWT token.' })\n @ApiForbiddenResponse({ description: 'Insufficient permissions (ADMIN required).' })`
|
|
619
|
+
: "";
|
|
620
|
+
const publicDecorator = useAuth ? ` @Public()` : "";
|
|
621
|
+
|
|
622
|
+
return `${nestCoreImports}
|
|
565
623
|
${swaggerImports}import { ${entityCap}Service } from '../services/${entityLower}.service';
|
|
566
624
|
import { Create${entityCap}Dto, Update${entityCap}Dto } from '../dtos/${entityLower}.dto';
|
|
625
|
+
${guardImports}
|
|
567
626
|
|
|
568
627
|
${swaggerDecorators}@Controller('${pluralName}')
|
|
569
628
|
export class ${entityCap}Controller {
|
|
@@ -571,10 +630,12 @@ export class ${entityCap}Controller {
|
|
|
571
630
|
|
|
572
631
|
constructor(private readonly service: ${entityCap}Service) {}
|
|
573
632
|
|
|
633
|
+
${apiBearerAuth}
|
|
634
|
+
${adminGuards}
|
|
574
635
|
@Post()
|
|
575
636
|
${
|
|
576
637
|
useSwagger
|
|
577
|
-
? `@ApiOperation({ summary: 'Create a new ${entityLower}' })\n @ApiResponse({ status: 201, description: 'The record has been successfully created.' })`
|
|
638
|
+
? `@ApiOperation({ summary: 'Create a new ${entityLower}' })\n @ApiResponse({ status: 201, description: 'The record has been successfully created.' })\n ${apiUnauthorized}`
|
|
578
639
|
: ""
|
|
579
640
|
}
|
|
580
641
|
async create(@Body() dto: Create${entityCap}Dto) {
|
|
@@ -585,17 +646,24 @@ export class ${entityCap}Controller {
|
|
|
585
646
|
};
|
|
586
647
|
}
|
|
587
648
|
|
|
649
|
+
${publicDecorator}
|
|
588
650
|
@Get()
|
|
589
651
|
${
|
|
590
652
|
useSwagger
|
|
591
|
-
? `@ApiOperation({ summary: 'Get all ${pluralName}' })\n @ApiResponse({ status: 200, description: 'List of records retrieved.' })`
|
|
653
|
+
? `@ApiOperation({ summary: 'Get all ${pluralName}' })\n @ApiQuery({ name: 'page', required: false, type: Number, schema: { default: 1 } })\n @ApiQuery({ name: 'limit', required: false, type: Number, schema: { default: 10 } })\n @ApiResponse({ status: 200, description: 'List of records retrieved.' })`
|
|
592
654
|
: ""
|
|
593
655
|
}
|
|
594
|
-
async findAll(
|
|
595
|
-
|
|
596
|
-
|
|
656
|
+
async findAll(
|
|
657
|
+
@Query('page') page?: number,
|
|
658
|
+
@Query('limit') limit?: number,
|
|
659
|
+
) {
|
|
660
|
+
this.logger.log(\`Fetching all ${pluralName} (page: \${page}, limit: \${limit})\`);
|
|
661
|
+
const pageNum = page ? Number(page) : 1;
|
|
662
|
+
const limitNum = limit ? Number(limit) : 10;
|
|
663
|
+
return await this.service.findAll(pageNum, limitNum);
|
|
597
664
|
}
|
|
598
665
|
|
|
666
|
+
${publicDecorator}
|
|
599
667
|
@Get(':id')
|
|
600
668
|
${
|
|
601
669
|
useSwagger
|
|
@@ -607,10 +675,12 @@ export class ${entityCap}Controller {
|
|
|
607
675
|
return await this.service.findById(id);
|
|
608
676
|
}
|
|
609
677
|
|
|
678
|
+
${apiBearerAuth}
|
|
679
|
+
${adminGuards}
|
|
610
680
|
@Patch(':id')
|
|
611
681
|
${
|
|
612
682
|
useSwagger
|
|
613
|
-
? `@ApiOperation({ summary: 'Update ${entityLower}' })\n @ApiParam({ name: 'id', type: String })\n @ApiResponse({ status: 200, description: 'Record updated.' })`
|
|
683
|
+
? `@ApiOperation({ summary: 'Update ${entityLower}' })\n @ApiParam({ name: 'id', type: String })\n @ApiResponse({ status: 200, description: 'Record updated.' })\n ${apiUnauthorized}`
|
|
614
684
|
: ""
|
|
615
685
|
}
|
|
616
686
|
async update(@Param('id') id: string, @Body() dto: Update${entityCap}Dto) {
|
|
@@ -619,11 +689,13 @@ export class ${entityCap}Controller {
|
|
|
619
689
|
return { message: '${entityCap} updated successfully' };
|
|
620
690
|
}
|
|
621
691
|
|
|
692
|
+
${apiBearerAuth}
|
|
693
|
+
${adminGuards}
|
|
622
694
|
@Delete(':id')
|
|
623
695
|
@HttpCode(HttpStatus.NO_CONTENT)
|
|
624
696
|
${
|
|
625
697
|
useSwagger
|
|
626
|
-
? `@ApiOperation({ summary: 'Delete ${entityLower}' })\n @ApiParam({ name: 'id', type: String })\n @ApiResponse({ status: 204, description: 'Record deleted.' })`
|
|
698
|
+
? `@ApiOperation({ summary: 'Delete ${entityLower}' })\n @ApiParam({ name: 'id', type: String })\n @ApiResponse({ status: 204, description: 'Record deleted.' })\n ${apiUnauthorized}`
|
|
627
699
|
: ""
|
|
628
700
|
}
|
|
629
701
|
async delete(@Param('id') id: string) {
|
|
@@ -631,7 +703,7 @@ export class ${entityCap}Controller {
|
|
|
631
703
|
await this.service.delete(id);
|
|
632
704
|
return {
|
|
633
705
|
message: '${entityCap} deleted successfully',
|
|
634
|
-
}
|
|
706
|
+
};
|
|
635
707
|
}
|
|
636
708
|
}`;
|
|
637
709
|
}
|
package/utils/envGenerator.js
CHANGED
|
@@ -21,7 +21,8 @@ PORT=3000
|
|
|
21
21
|
`;
|
|
22
22
|
|
|
23
23
|
// --- AUTHENTICATION SECTION ---
|
|
24
|
-
|
|
24
|
+
if (inputs.useAuth) {
|
|
25
|
+
content += `# ------------------------------------------------------------------------------
|
|
25
26
|
# AUTHENTICATION (JWT)
|
|
26
27
|
# ------------------------------------------------------------------------------
|
|
27
28
|
# Auto-generated secrets to secure tokens.
|
|
@@ -34,6 +35,7 @@ JWT_EXPIRES_IN=15m
|
|
|
34
35
|
JWT_REFRESH_EXPIRES_IN=7d
|
|
35
36
|
|
|
36
37
|
`;
|
|
38
|
+
}
|
|
37
39
|
|
|
38
40
|
// --- DATABASE SECTION ---
|
|
39
41
|
content += `# ------------------------------------------------------------------------------
|
|
@@ -18,8 +18,11 @@ async function saveProjectConfig(inputs) {
|
|
|
18
18
|
database: inputs.selectedDB,
|
|
19
19
|
auth: inputs.useAuth,
|
|
20
20
|
swagger: inputs.useSwagger,
|
|
21
|
+
throttler: inputs.useThrottler,
|
|
21
22
|
packageManager: inputs.packageManager,
|
|
22
23
|
docker: inputs.useDocker,
|
|
24
|
+
entities: inputs.entitiesData ? inputs.entitiesData.entities : [],
|
|
25
|
+
relations: inputs.entitiesData ? inputs.entitiesData.relations : [],
|
|
23
26
|
generatedAt: new Date().toISOString(),
|
|
24
27
|
};
|
|
25
28
|
|