@koalarx/nest 4.0.3 → 4.0.6
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/cli/commands/new/configure-test-runner.js +13 -2
- package/cli/commands/new/create-ddd-structure.js +2 -4
- package/cli/commands/new/index.js +5 -1
- package/cli/constants/cli-project-checklist.js +29 -3
- package/cli/utils/install-module.js +5 -0
- package/cli/utils/install-workspace-config.js +81 -0
- package/cli/utils/patch-app-test-module.js +29 -0
- package/cli/utils/remove-sample-parts.js +10 -3
- package/koala-nest/.vscode/extensions.json +7 -0
- package/koala-nest/.vscode/launch.json +16 -0
- package/koala-nest/.vscode/settings.json +77 -0
- package/koala-nest/.vscode/tasks.json +46 -0
- package/koala-nest/src/application/common/request-validator.base.ts +36 -1
- package/koala-nest/src/core/tools/mapping/auto-mapper.ts +5 -1
- package/koala-nest/src/core/tools/mapping/mapping-store.ts +22 -14
- package/koala-nest/src/infra/services/database.indicator.service.ts +21 -4
- package/koala-nest/src/test/application/read-many-person.handler.spec.ts +42 -2
- package/koala-nest/src/test/core/mapping.spec.ts +61 -0
- package/koala-nest/src/test/host/controllers/app/app.e2e.spec.ts +24 -0
- package/koala-nest/src/test/utils/create-e2e-database.ts +17 -1
- package/koala-nest/vitest.config.e2e.ts +13 -0
- package/koala-nest/vitest.config.ts +12 -0
- package/package.json +1 -1
|
@@ -6,8 +6,19 @@ export function configureTestRunner(packageJson, packageManager) {
|
|
|
6
6
|
scripts["test:watch"] = "bun test --watch";
|
|
7
7
|
return;
|
|
8
8
|
}
|
|
9
|
-
scripts.test = "vitest run";
|
|
10
|
-
scripts["test:watch"] = "vitest";
|
|
9
|
+
scripts.test = "vitest run --config vitest.config.ts";
|
|
10
|
+
scripts["test:watch"] = "vitest --config vitest.config.ts";
|
|
11
11
|
devDependencies.vitest = "^4.1.8";
|
|
12
12
|
devDependencies["vite-tsconfig-paths"] = "^5.1.4";
|
|
13
13
|
}
|
|
14
|
+
export function configureE2ETestRunner(packageJson, packageManager) {
|
|
15
|
+
const scripts = packageJson.scripts;
|
|
16
|
+
const devDependencies = packageJson.devDependencies;
|
|
17
|
+
devDependencies.supertest ??= "^7.1.0";
|
|
18
|
+
devDependencies["@types/supertest"] ??= "^6.0.2";
|
|
19
|
+
if (packageManager === "bun") {
|
|
20
|
+
scripts["test:e2e"] = "bun test --preload ./src/test/setup-e2e.ts src/test/host/controllers/";
|
|
21
|
+
return;
|
|
22
|
+
}
|
|
23
|
+
scripts["test:e2e"] = "vitest run --config vitest.config.e2e.ts";
|
|
24
|
+
}
|
|
@@ -3,7 +3,7 @@ import path from "node:path";
|
|
|
3
3
|
import { DDD_LAYER_FOLDERS } from "../../constants/domain.js";
|
|
4
4
|
import { patchGeneratedProjectConfig } from "../../utils/patch-generated-project.js";
|
|
5
5
|
import { runCommand } from "../../utils/run-command.js";
|
|
6
|
-
import { configureTestRunner } from "./configure-test-runner.js";
|
|
6
|
+
import { configureE2ETestRunner, configureTestRunner } from "./configure-test-runner.js";
|
|
7
7
|
export async function createDDDStructure(projectName, packageManager) {
|
|
8
8
|
const folders = [...DDD_LAYER_FOLDERS];
|
|
9
9
|
for (const folder of folders) {
|
|
@@ -38,14 +38,12 @@ export async function createDDDStructure(projectName, packageManager) {
|
|
|
38
38
|
packageJson.scripts["migration:revert"] = `${migrationRunner} ${typeormCli} migration:revert ${migrationDatasource}`;
|
|
39
39
|
packageJson.devDependencies ??= {};
|
|
40
40
|
configureTestRunner(packageJson, packageManager);
|
|
41
|
+
configureE2ETestRunner(packageJson, packageManager);
|
|
41
42
|
delete packageJson.scripts["test:cov"];
|
|
42
43
|
delete packageJson.scripts["test:debug"];
|
|
43
|
-
delete packageJson.scripts["test:e2e"];
|
|
44
44
|
delete packageJson.jest;
|
|
45
45
|
delete packageJson.devDependencies["@types/jest"];
|
|
46
|
-
delete packageJson.devDependencies["@types/supertest"];
|
|
47
46
|
delete packageJson.devDependencies["ts-jest"];
|
|
48
|
-
delete packageJson.devDependencies["supertest"];
|
|
49
47
|
writeFileSync(path.join(process.cwd(), projectName, "package.json"), JSON.stringify(packageJson, null, 2));
|
|
50
48
|
patchGeneratedProjectConfig(path.join(process.cwd(), projectName));
|
|
51
49
|
await runCommand([packageManager, "install"], path.join(process.cwd(), projectName));
|
|
@@ -26,6 +26,7 @@ import {
|
|
|
26
26
|
resolveProjectFeatures
|
|
27
27
|
} from "../../utils/install-module.js";
|
|
28
28
|
import { fixLintConfig } from "./fix-lint-config.js";
|
|
29
|
+
import { finalizeNewProjectSetup } from "../../utils/install-workspace-config.js";
|
|
29
30
|
async function promptAuthStrategies(template) {
|
|
30
31
|
const isCrud = template === Template.CRUD_SAMPLE;
|
|
31
32
|
return assertNotCancel(await p.multiselect({
|
|
@@ -180,6 +181,8 @@ export async function runNew(args = []) {
|
|
|
180
181
|
auth: authStrategies,
|
|
181
182
|
features
|
|
182
183
|
});
|
|
184
|
+
spinner.message("Configurando workspace (.vscode e .env)...");
|
|
185
|
+
finalizeNewProjectSetup(project.name, project.packageManager);
|
|
183
186
|
spinner.stop("Projeto criado com sucesso!");
|
|
184
187
|
const projectFeatures = resolveProjectFeatures(features, authStrategies);
|
|
185
188
|
const extrasSummary = [
|
|
@@ -196,7 +199,8 @@ export async function runNew(args = []) {
|
|
|
196
199
|
`${color.bold("Gerenciador:")} ${project.packageManager}`,
|
|
197
200
|
`${color.bold("Autenticação:")} ${formatAuthStrategies(authStrategies)}`,
|
|
198
201
|
`${color.bold("Extras:")} ${extrasSummary || color.dim("nenhum")}`,
|
|
199
|
-
`${color.dim("Depois:")} cd ${project.name} &&
|
|
202
|
+
`${color.dim("Depois:")} cd ${project.name} && ${project.packageManager} start`,
|
|
203
|
+
`${color.dim("Extras:")} kl-nest add <feature>`
|
|
200
204
|
].join(`
|
|
201
205
|
`), "Resumo");
|
|
202
206
|
}
|
|
@@ -5,6 +5,28 @@ import {
|
|
|
5
5
|
resolveNewProjectOptions
|
|
6
6
|
} from "./domain.js";
|
|
7
7
|
import { resolveProjectFeatures } from "../utils/install-module.js";
|
|
8
|
+
export const E2E_INFRA_PATHS = [
|
|
9
|
+
"src/test/setup-e2e.ts",
|
|
10
|
+
"src/test/create-e2e-test-app.ts",
|
|
11
|
+
"src/test/e2e-context.ts",
|
|
12
|
+
"src/test/utils/create-e2e-database.ts",
|
|
13
|
+
"src/test/utils/e2e-database-client.ts",
|
|
14
|
+
"src/test/host/controllers/app/app.e2e.spec.ts"
|
|
15
|
+
];
|
|
16
|
+
export const CRUD_E2E_EXAMPLE_PATHS = [
|
|
17
|
+
"src/test/host/controllers/person/person.controller.e2e.spec.ts",
|
|
18
|
+
"src/test/host/controllers/person/lazy-loading.e2e.spec.ts",
|
|
19
|
+
"src/test/host/controllers/auth/auth.controller.e2e.spec.ts",
|
|
20
|
+
"src/test/app-auth-test.module.ts",
|
|
21
|
+
"src/test/create-auth-e2e-test-app.ts"
|
|
22
|
+
];
|
|
23
|
+
export const WORKSPACE_SETUP_PATHS = [
|
|
24
|
+
".vscode/launch.json",
|
|
25
|
+
".vscode/settings.json",
|
|
26
|
+
".vscode/tasks.json",
|
|
27
|
+
".vscode/extensions.json",
|
|
28
|
+
".env"
|
|
29
|
+
];
|
|
8
30
|
export const CORE_REQUIRED_PATHS = [
|
|
9
31
|
"src/core/env.ts",
|
|
10
32
|
"src/host/main.ts",
|
|
@@ -165,9 +187,13 @@ export function buildProjectExpectation(template, auth, features) {
|
|
|
165
187
|
};
|
|
166
188
|
}
|
|
167
189
|
export function requiredPathsForExpectation(expectation) {
|
|
168
|
-
const paths = [
|
|
190
|
+
const paths = [
|
|
191
|
+
...CORE_REQUIRED_PATHS,
|
|
192
|
+
...WORKSPACE_SETUP_PATHS,
|
|
193
|
+
...E2E_INFRA_PATHS
|
|
194
|
+
];
|
|
169
195
|
if (expectation.template === Template.CRUD_SAMPLE) {
|
|
170
|
-
paths.push(...CRUD_TEMPLATE_REQUIRED_PATHS);
|
|
196
|
+
paths.push(...CRUD_TEMPLATE_REQUIRED_PATHS, ...CRUD_E2E_EXAMPLE_PATHS);
|
|
171
197
|
}
|
|
172
198
|
if (expectation.cache === "redis") {
|
|
173
199
|
paths.push(...CACHE_REDIS_REQUIRED_PATHS);
|
|
@@ -189,7 +215,7 @@ export function requiredPathsForExpectation(expectation) {
|
|
|
189
215
|
export function forbiddenPathsForExpectation(expectation) {
|
|
190
216
|
const paths = [];
|
|
191
217
|
if (expectation.template === Template.DEFAULT) {
|
|
192
|
-
paths.push(...DEFAULT_TEMPLATE_FORBIDDEN_PATHS);
|
|
218
|
+
paths.push(...DEFAULT_TEMPLATE_FORBIDDEN_PATHS, ...CRUD_E2E_EXAMPLE_PATHS);
|
|
193
219
|
} else {
|
|
194
220
|
paths.push(...CRUD_TEMPLATE_FORBIDDEN_PATHS);
|
|
195
221
|
}
|
|
@@ -153,6 +153,11 @@ export async function installModule(module, template, projectName = "", options
|
|
|
153
153
|
for (const configFile of ["tsconfig.build.json", ".env.example"]) {
|
|
154
154
|
cpSync(path.join(getSourceCodePath(), configFile), path.join(projectPath, configFile));
|
|
155
155
|
}
|
|
156
|
+
if (packageManager !== "bun") {
|
|
157
|
+
for (const configFile of ["vitest.config.ts", "vitest.config.e2e.ts"]) {
|
|
158
|
+
cpSync(path.join(getSourceCodePath(), configFile), path.join(projectPath, configFile));
|
|
159
|
+
}
|
|
160
|
+
}
|
|
156
161
|
patchInfraModuleFile(projectName, false);
|
|
157
162
|
patchMainFile(projectName, stripMainOptionalFeatures);
|
|
158
163
|
if (!options.skipPackages) {
|
|
@@ -0,0 +1,81 @@
|
|
|
1
|
+
import { cpSync, existsSync, readFileSync, writeFileSync } from "node:fs";
|
|
2
|
+
import path from "node:path";
|
|
3
|
+
import { getSourceCodePath } from "./get-source-code-path.js";
|
|
4
|
+
import { resolveProjectPath } from "./resolve-project-path.js";
|
|
5
|
+
const PACKAGE_MANAGER_COMMAND = {
|
|
6
|
+
bun: "bun",
|
|
7
|
+
npm: "npm",
|
|
8
|
+
pnpm: "pnpm"
|
|
9
|
+
};
|
|
10
|
+
export function projectNameToSnakeCase(projectName) {
|
|
11
|
+
const baseName = path.isAbsolute(projectName) ? path.basename(projectName) : projectName;
|
|
12
|
+
return baseName.replace(/([a-z0-9])([A-Z])/g, "$1_$2").replace(/[\s-]+/g, "_").replace(/__+/g, "_").toLowerCase().replace(/^_+|_+$/g, "");
|
|
13
|
+
}
|
|
14
|
+
function patchDatabaseUrl(content, databaseName) {
|
|
15
|
+
if (!/^DATABASE_URL=/m.test(content)) {
|
|
16
|
+
return content;
|
|
17
|
+
}
|
|
18
|
+
return content.replace(/^(DATABASE_URL=.+\/)([^/\r\n]+)(\s*)$/m, `$1${databaseName}$3`);
|
|
19
|
+
}
|
|
20
|
+
function patchJsonFile(filePath, patch) {
|
|
21
|
+
const content = readFileSync(filePath, "utf8");
|
|
22
|
+
const json = JSON.parse(content);
|
|
23
|
+
patch(json);
|
|
24
|
+
writeFileSync(filePath, `${JSON.stringify(json, null, 2)}
|
|
25
|
+
`);
|
|
26
|
+
}
|
|
27
|
+
function runScriptCommand(packageManager, script) {
|
|
28
|
+
return `${PACKAGE_MANAGER_COMMAND[packageManager]} run ${script}`;
|
|
29
|
+
}
|
|
30
|
+
export function installWorkspaceConfig(projectName, packageManager) {
|
|
31
|
+
const projectRoot = resolveProjectPath(projectName);
|
|
32
|
+
const sourceVscode = path.join(getSourceCodePath(), ".vscode");
|
|
33
|
+
const targetVscode = path.join(projectRoot, ".vscode");
|
|
34
|
+
cpSync(sourceVscode, targetVscode, { recursive: true });
|
|
35
|
+
const pmCommand = PACKAGE_MANAGER_COMMAND[packageManager];
|
|
36
|
+
patchJsonFile(path.join(targetVscode, "launch.json"), (launch) => {
|
|
37
|
+
const config = launch.configurations[0];
|
|
38
|
+
config.runtimeExecutable = pmCommand;
|
|
39
|
+
config.runtimeArgs = ["run", "start:debug"];
|
|
40
|
+
config.cwd = "${workspaceFolder}";
|
|
41
|
+
});
|
|
42
|
+
patchJsonFile(path.join(targetVscode, "tasks.json"), (tasks) => {
|
|
43
|
+
for (const task of tasks.tasks) {
|
|
44
|
+
if (task.command.includes("start:dev")) {
|
|
45
|
+
task.command = runScriptCommand(packageManager, "start:dev");
|
|
46
|
+
} else if (task.command.includes("test")) {
|
|
47
|
+
task.command = runScriptCommand(packageManager, "test");
|
|
48
|
+
} else if (task.command.includes("test:e2e")) {
|
|
49
|
+
task.command = runScriptCommand(packageManager, "test:e2e");
|
|
50
|
+
} else if (task.command.includes("migration:run")) {
|
|
51
|
+
task.command = runScriptCommand(packageManager, "migration:run");
|
|
52
|
+
}
|
|
53
|
+
task.options = { cwd: "${workspaceFolder}" };
|
|
54
|
+
}
|
|
55
|
+
});
|
|
56
|
+
patchJsonFile(path.join(targetVscode, "settings.json"), (settings) => {
|
|
57
|
+
settings["npm.packageManager"] = packageManager;
|
|
58
|
+
});
|
|
59
|
+
const gitignoreSource = path.join(getSourceCodePath(), ".gitignore");
|
|
60
|
+
if (existsSync(gitignoreSource)) {
|
|
61
|
+
cpSync(gitignoreSource, path.join(projectRoot, ".gitignore"), {
|
|
62
|
+
force: true
|
|
63
|
+
});
|
|
64
|
+
}
|
|
65
|
+
}
|
|
66
|
+
export function createEnvFromExample(projectName) {
|
|
67
|
+
const projectRoot = resolveProjectPath(projectName);
|
|
68
|
+
const examplePath = path.join(projectRoot, ".env.example");
|
|
69
|
+
const envPath = path.join(projectRoot, ".env");
|
|
70
|
+
if (!existsSync(examplePath)) {
|
|
71
|
+
return;
|
|
72
|
+
}
|
|
73
|
+
const databaseName = projectNameToSnakeCase(projectName);
|
|
74
|
+
const envContent = patchDatabaseUrl(readFileSync(examplePath, "utf8"), databaseName);
|
|
75
|
+
writeFileSync(examplePath, envContent);
|
|
76
|
+
writeFileSync(envPath, envContent);
|
|
77
|
+
}
|
|
78
|
+
export function finalizeNewProjectSetup(projectName, packageManager) {
|
|
79
|
+
installWorkspaceConfig(projectName, packageManager);
|
|
80
|
+
createEnvFromExample(projectName);
|
|
81
|
+
}
|
|
@@ -0,0 +1,29 @@
|
|
|
1
|
+
import { writeFileSync } from "node:fs";
|
|
2
|
+
import path from "node:path";
|
|
3
|
+
import { resolveProjectPath } from "./resolve-project-path.js";
|
|
4
|
+
const defaultAppTestModule = `import { envSchema } from '@/core/env';
|
|
5
|
+
import { Module } from '@nestjs/common';
|
|
6
|
+
import { ConfigModule } from '@nestjs/config';
|
|
7
|
+
import { InfraModule } from '@/infra/infra.module';
|
|
8
|
+
import { e2eDatabaseUrl } from '@/test/e2e-context';
|
|
9
|
+
|
|
10
|
+
@Module({
|
|
11
|
+
imports: [
|
|
12
|
+
ConfigModule.forRoot({
|
|
13
|
+
isGlobal: true,
|
|
14
|
+
ignoreEnvFile: true,
|
|
15
|
+
validate: () =>
|
|
16
|
+
envSchema.parse({
|
|
17
|
+
PORT: 3000,
|
|
18
|
+
NODE_ENV: 'test',
|
|
19
|
+
DATABASE_URL: e2eDatabaseUrl,
|
|
20
|
+
}),
|
|
21
|
+
}),
|
|
22
|
+
InfraModule,
|
|
23
|
+
],
|
|
24
|
+
})
|
|
25
|
+
export class AppTestModule {}
|
|
26
|
+
`;
|
|
27
|
+
export function patchAppTestModuleForDefault(projectName) {
|
|
28
|
+
writeFileSync(path.join(resolveProjectPath(projectName), "src/test/app-test.module.ts"), defaultAppTestModule);
|
|
29
|
+
}
|
|
@@ -2,6 +2,7 @@ import { existsSync, readFileSync, rmSync, writeFileSync } from "node:fs";
|
|
|
2
2
|
import path from "node:path";
|
|
3
3
|
import { removeImportLines } from "./project-files.js";
|
|
4
4
|
import { patchAppModuleJobs } from "./patch-jobs-module.js";
|
|
5
|
+
import { patchAppTestModuleForDefault } from "./patch-app-test-module.js";
|
|
5
6
|
import { resolveProjectPath } from "./resolve-project-path.js";
|
|
6
7
|
import { stripMainOptionalFeatures } from "./patch-main.js";
|
|
7
8
|
import { stripDefineDocumentationAuth } from "./patch-define-documentation.js";
|
|
@@ -85,11 +86,13 @@ const partsToRemove = [
|
|
|
85
86
|
replace: [{ from: "PersonMapper.createMap();", to: "" }]
|
|
86
87
|
}
|
|
87
88
|
];
|
|
88
|
-
const
|
|
89
|
+
const defaultSampleTestPathsToRemove = [
|
|
89
90
|
"src/test/application",
|
|
90
91
|
"src/test/mockup/person",
|
|
91
92
|
"src/test/host/controllers/person/person.controller.e2e.spec.ts",
|
|
92
|
-
"src/test/host/controllers/person/lazy-loading.e2e.spec.ts"
|
|
93
|
+
"src/test/host/controllers/person/lazy-loading.e2e.spec.ts"
|
|
94
|
+
];
|
|
95
|
+
const defaultAuthE2ePathsToRemove = [
|
|
93
96
|
"src/test/host/controllers/auth/auth.controller.e2e.spec.ts",
|
|
94
97
|
"src/test/app-auth-test.module.ts",
|
|
95
98
|
"src/test/create-auth-e2e-test-app.ts"
|
|
@@ -154,7 +157,11 @@ export async function removeSampleParts(projectName) {
|
|
|
154
157
|
writeFileSync(partPath, content, "utf8");
|
|
155
158
|
}
|
|
156
159
|
patchAppModuleJobs(projectName, { eventHandlers: [], cronJobs: [] });
|
|
157
|
-
|
|
160
|
+
patchAppTestModuleForDefault(projectName);
|
|
161
|
+
removePaths(projectName, [
|
|
162
|
+
...defaultSampleTestPathsToRemove,
|
|
163
|
+
...defaultAuthE2ePathsToRemove
|
|
164
|
+
]);
|
|
158
165
|
}
|
|
159
166
|
export async function cleanDefaultTemplateWithoutAuth(projectName) {
|
|
160
167
|
stripDefaultProjectAuth(projectName);
|
|
@@ -0,0 +1,16 @@
|
|
|
1
|
+
{
|
|
2
|
+
"version": "0.2.0",
|
|
3
|
+
"configurations": [
|
|
4
|
+
{
|
|
5
|
+
"type": "node",
|
|
6
|
+
"request": "launch",
|
|
7
|
+
"name": "NestJS Debug",
|
|
8
|
+
"runtimeExecutable": "bun",
|
|
9
|
+
"runtimeArgs": ["run", "start:debug"],
|
|
10
|
+
"cwd": "${workspaceFolder}",
|
|
11
|
+
"autoAttachChildProcesses": true,
|
|
12
|
+
"restart": true,
|
|
13
|
+
"console": "integratedTerminal"
|
|
14
|
+
}
|
|
15
|
+
]
|
|
16
|
+
}
|
|
@@ -0,0 +1,77 @@
|
|
|
1
|
+
{
|
|
2
|
+
"material-icon-theme.activeIconPack": "nest",
|
|
3
|
+
"editor.fontFamily": "Fira Code",
|
|
4
|
+
"editor.fontSize": 15,
|
|
5
|
+
"editor.fontLigatures": true,
|
|
6
|
+
"workbench.startupEditor": "newUntitledFile",
|
|
7
|
+
"js/ts.suggest.autoImports": true,
|
|
8
|
+
"js/ts.updateImportsOnFileMove.enabled": "always",
|
|
9
|
+
"editor.rulers": [80, 120],
|
|
10
|
+
"extensions.ignoreRecommendations": true,
|
|
11
|
+
"files.associations": {
|
|
12
|
+
".env.*": "dotenv",
|
|
13
|
+
".prettierrc": "json"
|
|
14
|
+
},
|
|
15
|
+
"editor.parameterHints.enabled": false,
|
|
16
|
+
"editor.renderLineHighlight": "gutter",
|
|
17
|
+
"editor.lineHeight": 26,
|
|
18
|
+
"editor.suggestSelection": "first",
|
|
19
|
+
"explorer.confirmDelete": false,
|
|
20
|
+
"workbench.editor.labelFormat": "short",
|
|
21
|
+
"editor.acceptSuggestionOnCommitCharacter": false,
|
|
22
|
+
"explorer.compactFolders": false,
|
|
23
|
+
"git.enableSmartCommit": true,
|
|
24
|
+
"explorer.confirmDragAndDrop": false,
|
|
25
|
+
"terminal.integrated.fontSize": 14,
|
|
26
|
+
"breadcrumbs.enabled": true,
|
|
27
|
+
"editor.tabSize": 2,
|
|
28
|
+
"workbench.iconTheme": "material-icon-theme",
|
|
29
|
+
"material-icon-theme.languages.associations": {
|
|
30
|
+
"dotenv": "tune"
|
|
31
|
+
},
|
|
32
|
+
"material-icon-theme.files.associations": {
|
|
33
|
+
"*.e2e-spec.ts": "test-js",
|
|
34
|
+
"*.dto.ts": "diff",
|
|
35
|
+
"*.handler.ts": "esbuild",
|
|
36
|
+
"*.request.ts": "log",
|
|
37
|
+
"*.schema.ts": "scheme",
|
|
38
|
+
"*.response.ts": "conduct",
|
|
39
|
+
"*.validator.ts": "commitlint"
|
|
40
|
+
},
|
|
41
|
+
"material-icon-theme.folders.associations": {
|
|
42
|
+
"entities": "class",
|
|
43
|
+
"migrations": "tools",
|
|
44
|
+
"schemas": "class",
|
|
45
|
+
"domain": "class",
|
|
46
|
+
"infra": "app",
|
|
47
|
+
"dtos": "typescript",
|
|
48
|
+
"repositories": "mappings"
|
|
49
|
+
},
|
|
50
|
+
"[javascript]": {
|
|
51
|
+
"editor.defaultFormatter": "esbenp.prettier-vscode",
|
|
52
|
+
"editor.formatOnSave": true,
|
|
53
|
+
"editor.codeActionsOnSave": {
|
|
54
|
+
"source.fixAll.eslint": "explicit"
|
|
55
|
+
}
|
|
56
|
+
},
|
|
57
|
+
"[typescript]": {
|
|
58
|
+
"editor.defaultFormatter": "esbenp.prettier-vscode",
|
|
59
|
+
"editor.formatOnSave": true,
|
|
60
|
+
"editor.codeActionsOnSave": {
|
|
61
|
+
"source.fixAll.eslint": "explicit"
|
|
62
|
+
}
|
|
63
|
+
},
|
|
64
|
+
"prettier.documentSelectors": ["**/*.ts", "**/*.tsx", "**/*.js"],
|
|
65
|
+
"npm.scriptExplorerAction": "run",
|
|
66
|
+
"npm.packageManager": "bun",
|
|
67
|
+
"editor.wordWrap": "on",
|
|
68
|
+
"editor.wrappingIndent": "indent",
|
|
69
|
+
"eslint.workingDirectories": [{ "mode": "auto" }],
|
|
70
|
+
"eslint.validate": [
|
|
71
|
+
"javascript",
|
|
72
|
+
"javascriptreact",
|
|
73
|
+
"typescript",
|
|
74
|
+
"typescriptreact"
|
|
75
|
+
],
|
|
76
|
+
"files.eol": "\n"
|
|
77
|
+
}
|
|
@@ -0,0 +1,46 @@
|
|
|
1
|
+
{
|
|
2
|
+
"version": "2.0.0",
|
|
3
|
+
"tasks": [
|
|
4
|
+
{
|
|
5
|
+
"type": "shell",
|
|
6
|
+
"command": "bun run start:dev",
|
|
7
|
+
"options": {
|
|
8
|
+
"cwd": "${workspaceFolder}"
|
|
9
|
+
},
|
|
10
|
+
"group": {
|
|
11
|
+
"kind": "build",
|
|
12
|
+
"isDefault": true
|
|
13
|
+
},
|
|
14
|
+
"isBackground": true,
|
|
15
|
+
"problemMatcher": [],
|
|
16
|
+
"label": "Start API (dev)",
|
|
17
|
+
"detail": "nest start --watch"
|
|
18
|
+
},
|
|
19
|
+
{
|
|
20
|
+
"type": "shell",
|
|
21
|
+
"command": "bun run test",
|
|
22
|
+
"options": {
|
|
23
|
+
"cwd": "${workspaceFolder}"
|
|
24
|
+
},
|
|
25
|
+
"group": "test",
|
|
26
|
+
"label": "Run tests"
|
|
27
|
+
},
|
|
28
|
+
{
|
|
29
|
+
"type": "shell",
|
|
30
|
+
"command": "bun run test:e2e",
|
|
31
|
+
"options": {
|
|
32
|
+
"cwd": "${workspaceFolder}"
|
|
33
|
+
},
|
|
34
|
+
"group": "test",
|
|
35
|
+
"label": "Run E2E tests"
|
|
36
|
+
},
|
|
37
|
+
{
|
|
38
|
+
"type": "shell",
|
|
39
|
+
"command": "bun run migration:run",
|
|
40
|
+
"options": {
|
|
41
|
+
"cwd": "${workspaceFolder}"
|
|
42
|
+
},
|
|
43
|
+
"label": "Run migrations"
|
|
44
|
+
}
|
|
45
|
+
]
|
|
46
|
+
}
|
|
@@ -6,7 +6,7 @@ export abstract class RequestValidatorBase<
|
|
|
6
6
|
protected _request: Record<string, any>;
|
|
7
7
|
|
|
8
8
|
constructor(request: TRequest) {
|
|
9
|
-
this._request =
|
|
9
|
+
this._request = RequestValidatorBase.toValidationInput(request);
|
|
10
10
|
}
|
|
11
11
|
|
|
12
12
|
validate(): TRequest {
|
|
@@ -30,4 +30,39 @@ export abstract class RequestValidatorBase<
|
|
|
30
30
|
}
|
|
31
31
|
|
|
32
32
|
protected abstract get schema(): ZodType;
|
|
33
|
+
|
|
34
|
+
/**
|
|
35
|
+
* Objetos plain (query HTTP) passam direto. Instâncias de classe carregam
|
|
36
|
+
* defaults nos fields — removemos os que não foram alterados para o Zod
|
|
37
|
+
* aplicar defaults/transforms sem sobrescrever query params explícitos.
|
|
38
|
+
*/
|
|
39
|
+
private static toValidationInput<T extends Record<string, unknown>>(
|
|
40
|
+
request: T,
|
|
41
|
+
): Record<string, unknown> {
|
|
42
|
+
if (request === null || typeof request !== 'object') {
|
|
43
|
+
return {};
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
const prototype = Object.getPrototypeOf(request);
|
|
47
|
+
const isClassInstance =
|
|
48
|
+
prototype !== null &&
|
|
49
|
+
prototype !== Object.prototype &&
|
|
50
|
+
typeof prototype.constructor === 'function';
|
|
51
|
+
|
|
52
|
+
if (!isClassInstance) {
|
|
53
|
+
return { ...request };
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
const Constructor = prototype.constructor as new () => T;
|
|
57
|
+
const defaultValues = new Constructor() as Record<string, unknown>;
|
|
58
|
+
const input: Record<string, unknown> = { ...request };
|
|
59
|
+
|
|
60
|
+
for (const key of Object.keys(defaultValues)) {
|
|
61
|
+
if (Object.is(input[key], defaultValues[key])) {
|
|
62
|
+
delete input[key];
|
|
63
|
+
}
|
|
64
|
+
}
|
|
65
|
+
|
|
66
|
+
return input;
|
|
67
|
+
}
|
|
33
68
|
}
|
|
@@ -94,7 +94,11 @@ export class AutoMapper {
|
|
|
94
94
|
continue;
|
|
95
95
|
}
|
|
96
96
|
|
|
97
|
-
|
|
97
|
+
const sourceValue = data[sourceProp.name];
|
|
98
|
+
|
|
99
|
+
if (sourceValue !== undefined) {
|
|
100
|
+
targetInstance[targetPropName] = sourceValue;
|
|
101
|
+
}
|
|
98
102
|
}
|
|
99
103
|
|
|
100
104
|
return targetInstance;
|
|
@@ -57,32 +57,40 @@ export class MappingStore {
|
|
|
57
57
|
}
|
|
58
58
|
}
|
|
59
59
|
|
|
60
|
-
|
|
60
|
+
const parentPrototype = Object.getPrototypeOf(current.prototype);
|
|
61
|
+
current = (parentPrototype?.constructor as Type<any> | undefined) ?? null;
|
|
61
62
|
}
|
|
62
63
|
|
|
63
64
|
return props;
|
|
64
65
|
}
|
|
65
66
|
|
|
66
67
|
static getPropType(entity: Type<any>, propName: string) {
|
|
67
|
-
|
|
68
|
+
let current: Type<any> | null = entity;
|
|
68
69
|
|
|
69
|
-
|
|
70
|
-
|
|
71
|
-
`Property ${propName} not found in entity ${entity.name}`,
|
|
72
|
-
);
|
|
73
|
-
}
|
|
70
|
+
while (current?.prototype) {
|
|
71
|
+
const prop = this._entities.get(current)?.find((p) => p.name === propName);
|
|
74
72
|
|
|
75
|
-
|
|
73
|
+
if (prop) {
|
|
74
|
+
const { type, isArray } = prop;
|
|
76
75
|
|
|
77
|
-
|
|
78
|
-
|
|
79
|
-
|
|
76
|
+
if (!type) {
|
|
77
|
+
return null;
|
|
78
|
+
}
|
|
80
79
|
|
|
81
|
-
|
|
82
|
-
|
|
80
|
+
if (isArray && type instanceof Function) {
|
|
81
|
+
return (type as () => Type<any>)();
|
|
82
|
+
}
|
|
83
|
+
|
|
84
|
+
return type;
|
|
85
|
+
}
|
|
86
|
+
|
|
87
|
+
const parentPrototype = Object.getPrototypeOf(current.prototype);
|
|
88
|
+
current = (parentPrototype?.constructor as Type<any> | undefined) ?? null;
|
|
83
89
|
}
|
|
84
90
|
|
|
85
|
-
|
|
91
|
+
throw new Error(
|
|
92
|
+
`Property ${propName} not found in entity ${entity.name}`,
|
|
93
|
+
);
|
|
86
94
|
}
|
|
87
95
|
|
|
88
96
|
static add(
|
|
@@ -1,17 +1,34 @@
|
|
|
1
1
|
import { DATA_SOURCE_PROVIDER_TOKEN } from '@/infra/database/data-source-factory';
|
|
2
2
|
import { Inject, Injectable } from '@nestjs/common';
|
|
3
|
-
import {
|
|
3
|
+
import {
|
|
4
|
+
HealthIndicatorResult,
|
|
5
|
+
HealthIndicatorService,
|
|
6
|
+
} from '@nestjs/terminus';
|
|
4
7
|
import { DataSource } from 'typeorm';
|
|
5
8
|
|
|
6
9
|
@Injectable()
|
|
7
10
|
export class DatabaseIndicator {
|
|
8
11
|
constructor(
|
|
9
|
-
private readonly
|
|
12
|
+
private readonly healthIndicatorService: HealthIndicatorService,
|
|
10
13
|
@Inject(DATA_SOURCE_PROVIDER_TOKEN)
|
|
11
14
|
private readonly dataSource: DataSource,
|
|
12
15
|
) {}
|
|
13
16
|
|
|
14
|
-
isHealthy() {
|
|
15
|
-
|
|
17
|
+
async isHealthy(): Promise<HealthIndicatorResult> {
|
|
18
|
+
const indicator = this.healthIndicatorService.check('db');
|
|
19
|
+
|
|
20
|
+
try {
|
|
21
|
+
if (!this.dataSource.isInitialized) {
|
|
22
|
+
return indicator.down({ message: 'DataSource not initialized' });
|
|
23
|
+
}
|
|
24
|
+
|
|
25
|
+
await this.dataSource.query('SELECT 1');
|
|
26
|
+
|
|
27
|
+
return indicator.up();
|
|
28
|
+
} catch (error) {
|
|
29
|
+
const message = error instanceof Error ? error.message : String(error);
|
|
30
|
+
|
|
31
|
+
return indicator.down({ message });
|
|
32
|
+
}
|
|
16
33
|
}
|
|
17
34
|
}
|
|
@@ -21,18 +21,58 @@ describe('ReadManyPersonHandler', () => {
|
|
|
21
21
|
contacts: [],
|
|
22
22
|
});
|
|
23
23
|
|
|
24
|
+
let capturedPage: number | undefined;
|
|
24
25
|
const repository = {
|
|
25
|
-
findMany: async (
|
|
26
|
+
findMany: async (query: { page?: number; skip: () => number }) => {
|
|
27
|
+
capturedPage = query.page;
|
|
28
|
+
return { items: [person], count: 1 };
|
|
29
|
+
},
|
|
26
30
|
} as unknown as IPersonRepository;
|
|
27
31
|
|
|
28
32
|
const handler = new ReadManyPersonHandler(repository, new CacheStub());
|
|
29
|
-
const result = await handler.handle(
|
|
33
|
+
const result = await handler.handle({ page: '2', limit: '5' } as never);
|
|
30
34
|
|
|
35
|
+
expect(capturedPage).toBe(1);
|
|
31
36
|
expect(result.count).toBe(1);
|
|
32
37
|
expect(result.items).toHaveLength(1);
|
|
33
38
|
expect(result.items[0].name).toBe('Jane');
|
|
34
39
|
});
|
|
35
40
|
|
|
41
|
+
it('preserva paginação ao receber instância de ReadManyPersonRequest', async () => {
|
|
42
|
+
let capturedPage: number | undefined;
|
|
43
|
+
const repository = {
|
|
44
|
+
findMany: async (query: { page?: number }) => {
|
|
45
|
+
capturedPage = query.page;
|
|
46
|
+
return { items: [], count: 0 };
|
|
47
|
+
},
|
|
48
|
+
} as unknown as IPersonRepository;
|
|
49
|
+
|
|
50
|
+
const handler = new ReadManyPersonHandler(repository, new CacheStub());
|
|
51
|
+
const request = Object.assign(new ReadManyPersonRequest(), {
|
|
52
|
+
page: '2',
|
|
53
|
+
limit: '5',
|
|
54
|
+
});
|
|
55
|
+
|
|
56
|
+
await handler.handle(request);
|
|
57
|
+
|
|
58
|
+
expect(capturedPage).toBe(1);
|
|
59
|
+
});
|
|
60
|
+
|
|
61
|
+
it('aplica defaults de paginação quando a request não informa page', async () => {
|
|
62
|
+
let capturedPage: number | undefined;
|
|
63
|
+
const repository = {
|
|
64
|
+
findMany: async (query: { page?: number }) => {
|
|
65
|
+
capturedPage = query.page;
|
|
66
|
+
return { items: [], count: 0 };
|
|
67
|
+
},
|
|
68
|
+
} as unknown as IPersonRepository;
|
|
69
|
+
|
|
70
|
+
const handler = new ReadManyPersonHandler(repository, new CacheStub());
|
|
71
|
+
await handler.handle(new ReadManyPersonRequest());
|
|
72
|
+
|
|
73
|
+
expect(capturedPage).toBe(0);
|
|
74
|
+
});
|
|
75
|
+
|
|
36
76
|
it('reutiliza cache para a mesma consulta de listagem', async () => {
|
|
37
77
|
const person = Person.from({
|
|
38
78
|
id: 1,
|
|
@@ -137,6 +137,67 @@ describe('AutoMapper', () => {
|
|
|
137
137
|
expect(target.fullName).toBe('John Doe');
|
|
138
138
|
});
|
|
139
139
|
|
|
140
|
+
it('should map inherited properties from parent classes', () => {
|
|
141
|
+
class BaseRequest {
|
|
142
|
+
@AutoMap()
|
|
143
|
+
page?: number;
|
|
144
|
+
|
|
145
|
+
@AutoMap()
|
|
146
|
+
limit?: number;
|
|
147
|
+
}
|
|
148
|
+
|
|
149
|
+
class ChildRequest extends BaseRequest {
|
|
150
|
+
@AutoMap()
|
|
151
|
+
name?: string;
|
|
152
|
+
}
|
|
153
|
+
|
|
154
|
+
class BaseDto {
|
|
155
|
+
@AutoMap()
|
|
156
|
+
page?: number = 0;
|
|
157
|
+
|
|
158
|
+
@AutoMap()
|
|
159
|
+
limit?: number = 10;
|
|
160
|
+
}
|
|
161
|
+
|
|
162
|
+
class ChildDto extends BaseDto {
|
|
163
|
+
@AutoMap()
|
|
164
|
+
name?: string;
|
|
165
|
+
}
|
|
166
|
+
|
|
167
|
+
createMap(ChildRequest, ChildDto);
|
|
168
|
+
|
|
169
|
+
const source = Object.assign(new ChildRequest(), {
|
|
170
|
+
page: 2,
|
|
171
|
+
limit: 25,
|
|
172
|
+
name: 'John',
|
|
173
|
+
});
|
|
174
|
+
|
|
175
|
+
const target = AutoMapper.map(source, ChildRequest, ChildDto);
|
|
176
|
+
|
|
177
|
+
expect(target.page).toBe(2);
|
|
178
|
+
expect(target.limit).toBe(25);
|
|
179
|
+
expect(target.name).toBe('John');
|
|
180
|
+
});
|
|
181
|
+
|
|
182
|
+
it('should resolve inherited property types via getProps and getPropType', () => {
|
|
183
|
+
class BaseRequest {
|
|
184
|
+
@AutoMap()
|
|
185
|
+
page?: number;
|
|
186
|
+
}
|
|
187
|
+
|
|
188
|
+
class ChildRequest extends BaseRequest {
|
|
189
|
+
@AutoMap()
|
|
190
|
+
name?: string;
|
|
191
|
+
}
|
|
192
|
+
|
|
193
|
+
const props = MappingStore.getProps(ChildRequest).map((p) => p.name);
|
|
194
|
+
|
|
195
|
+
expect(props).toContain('page');
|
|
196
|
+
expect(props).toContain('name');
|
|
197
|
+
expect(MappingStore.getPropType(ChildRequest, 'page')).toBe(Number);
|
|
198
|
+
expect(MappingStore.getPropType(ChildRequest, 'name')).toBe(String);
|
|
199
|
+
});
|
|
200
|
+
|
|
140
201
|
it('should map nested objects using the target property name', () => {
|
|
141
202
|
class SourceChild {
|
|
142
203
|
@AutoMap()
|
|
@@ -0,0 +1,24 @@
|
|
|
1
|
+
/// <reference types="bun-types/test-globals" />
|
|
2
|
+
|
|
3
|
+
import { createE2ETestApp } from '@/test/create-e2e-test-app';
|
|
4
|
+
import type { INestApplication } from '@nestjs/common';
|
|
5
|
+
|
|
6
|
+
/**
|
|
7
|
+
* Estrutura mínima de E2E — use como ponto de partida no template Padrão.
|
|
8
|
+
* No exemplo CRUD, veja `person.controller.e2e.spec.ts` e `auth.controller.e2e.spec.ts`.
|
|
9
|
+
*/
|
|
10
|
+
describe('App (E2E)', () => {
|
|
11
|
+
let app: INestApplication;
|
|
12
|
+
|
|
13
|
+
beforeAll(async () => {
|
|
14
|
+
app = await createE2ETestApp();
|
|
15
|
+
});
|
|
16
|
+
|
|
17
|
+
afterAll(async () => {
|
|
18
|
+
await app.close();
|
|
19
|
+
});
|
|
20
|
+
|
|
21
|
+
it('should bootstrap the application', () => {
|
|
22
|
+
expect(app.getHttpServer()).toBeDefined();
|
|
23
|
+
});
|
|
24
|
+
});
|
|
@@ -1,9 +1,24 @@
|
|
|
1
1
|
import { Type } from '@nestjs/common';
|
|
2
2
|
import { execSync } from 'node:child_process';
|
|
3
3
|
import { randomUUID } from 'node:crypto';
|
|
4
|
+
import { readFileSync } from 'node:fs';
|
|
4
5
|
import { setE2EDatabaseUrl } from '../e2e-context';
|
|
5
6
|
import { E2EDatabaseClient } from './e2e-database-client';
|
|
6
7
|
|
|
8
|
+
function resolveMigrationRunner(): string {
|
|
9
|
+
const packageJson = JSON.parse(readFileSync('package.json', 'utf8')) as {
|
|
10
|
+
packageManager?: string;
|
|
11
|
+
};
|
|
12
|
+
|
|
13
|
+
const packageManager = packageJson.packageManager ?? 'bun';
|
|
14
|
+
|
|
15
|
+
if (packageManager === 'bun') {
|
|
16
|
+
return 'bun';
|
|
17
|
+
}
|
|
18
|
+
|
|
19
|
+
return 'node --import ts-node/register/transpile-only';
|
|
20
|
+
}
|
|
21
|
+
|
|
7
22
|
function generateUniqueDatabaseURL() {
|
|
8
23
|
const schemaId = randomUUID();
|
|
9
24
|
|
|
@@ -33,8 +48,9 @@ export async function createE2EDatabase<T extends E2EDatabaseClient>(
|
|
|
33
48
|
await client.createDatabase(schemaId);
|
|
34
49
|
|
|
35
50
|
const env = { ...process.env, DATABASE_URL: url, NODE_ENV: 'test' };
|
|
51
|
+
const migrationRunner = resolveMigrationRunner();
|
|
36
52
|
execSync(
|
|
37
|
-
|
|
53
|
+
`${migrationRunner} ./node_modules/typeorm/cli.js migration:run -d ./src/infra/database/migrations/migration-datasource.ts`,
|
|
38
54
|
{
|
|
39
55
|
cwd: process.cwd(),
|
|
40
56
|
env,
|
|
@@ -0,0 +1,13 @@
|
|
|
1
|
+
import { defineConfig } from 'vitest/config';
|
|
2
|
+
import tsconfigPaths from 'vite-tsconfig-paths';
|
|
3
|
+
|
|
4
|
+
export default defineConfig({
|
|
5
|
+
plugins: [tsconfigPaths()],
|
|
6
|
+
test: {
|
|
7
|
+
include: ['src/test/**/*.e2e.spec.ts'],
|
|
8
|
+
setupFiles: ['src/test/setup-e2e.ts'],
|
|
9
|
+
environment: 'node',
|
|
10
|
+
testTimeout: 60_000,
|
|
11
|
+
hookTimeout: 60_000,
|
|
12
|
+
},
|
|
13
|
+
});
|
|
@@ -0,0 +1,12 @@
|
|
|
1
|
+
import { defineConfig } from 'vitest/config';
|
|
2
|
+
import tsconfigPaths from 'vite-tsconfig-paths';
|
|
3
|
+
|
|
4
|
+
export default defineConfig({
|
|
5
|
+
plugins: [tsconfigPaths()],
|
|
6
|
+
test: {
|
|
7
|
+
include: ['src/test/**/*.spec.ts'],
|
|
8
|
+
exclude: ['src/test/**/*.e2e.spec.ts'],
|
|
9
|
+
setupFiles: ['src/test/setup.ts'],
|
|
10
|
+
environment: 'node',
|
|
11
|
+
},
|
|
12
|
+
});
|