@lenne.tech/nest-server 11.32.3 → 11.33.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/.claude/rules/architecture.md +16 -0
- package/.claude/rules/configurable-features.md +11 -3
- package/.claude/rules/testing.md +101 -1
- package/CLAUDE.md +5 -0
- package/FRAMEWORK-API.md +58 -3
- package/bin/migrate.js +13 -3
- package/dist/core/common/helpers/content-disposition.helper.d.ts +1 -0
- package/dist/core/common/helpers/content-disposition.helper.js +14 -0
- package/dist/core/common/helpers/content-disposition.helper.js.map +1 -0
- package/dist/core/common/helpers/file.helper.d.ts +21 -2
- package/dist/core/common/helpers/file.helper.js +69 -13
- package/dist/core/common/helpers/file.helper.js.map +1 -1
- package/dist/core/common/helpers/graceful-shutdown.helper.d.ts +2 -0
- package/dist/core/common/helpers/graceful-shutdown.helper.js +55 -0
- package/dist/core/common/helpers/graceful-shutdown.helper.js.map +1 -0
- package/dist/core/common/helpers/project-name.helper.d.ts +2 -0
- package/dist/core/common/helpers/project-name.helper.js +38 -0
- package/dist/core/common/helpers/project-name.helper.js.map +1 -0
- package/dist/core/common/interfaces/cron-job-config.interface.d.ts +1 -0
- package/dist/core/common/interfaces/server-options.interface.d.ts +39 -0
- package/dist/core/common/services/core-cron-jobs.initializer.d.ts +10 -0
- package/dist/core/common/services/core-cron-jobs.initializer.js +46 -0
- package/dist/core/common/services/core-cron-jobs.initializer.js.map +1 -0
- package/dist/core/common/services/core-cron-jobs.registry.d.ts +8 -0
- package/dist/core/common/services/core-cron-jobs.registry.js +12 -0
- package/dist/core/common/services/core-cron-jobs.registry.js.map +1 -0
- package/dist/core/common/services/core-cron-jobs.service.d.ts +37 -7
- package/dist/core/common/services/core-cron-jobs.service.js +278 -27
- package/dist/core/common/services/core-cron-jobs.service.js.map +1 -1
- package/dist/core/common/services/core-redis-pubsub.d.ts +20 -0
- package/dist/core/common/services/core-redis-pubsub.js +112 -0
- package/dist/core/common/services/core-redis-pubsub.js.map +1 -0
- package/dist/core/common/services/core-redis.service.d.ts +25 -0
- package/dist/core/common/services/core-redis.service.js +154 -0
- package/dist/core/common/services/core-redis.service.js.map +1 -0
- package/dist/core/common/services/core-s3.service.d.ts +51 -0
- package/dist/core/common/services/core-s3.service.js +261 -0
- package/dist/core/common/services/core-s3.service.js.map +1 -0
- package/dist/core/common/services/core-trust-proxy.initializer.d.ts +16 -0
- package/dist/core/common/services/core-trust-proxy.initializer.js +77 -0
- package/dist/core/common/services/core-trust-proxy.initializer.js.map +1 -0
- package/dist/core/common/services/rate-limit-store.d.ts +61 -0
- package/dist/core/common/services/rate-limit-store.js +235 -0
- package/dist/core/common/services/rate-limit-store.js.map +1 -0
- package/dist/core/modules/ai/core-ai-mcp.controller.d.ts +19 -3
- package/dist/core/modules/ai/core-ai-mcp.controller.js +148 -18
- package/dist/core/modules/ai/core-ai-mcp.controller.js.map +1 -1
- package/dist/core/modules/ai/services/core-ai.service.d.ts +7 -2
- package/dist/core/modules/ai/services/core-ai.service.js +24 -20
- package/dist/core/modules/ai/services/core-ai.service.js.map +1 -1
- package/dist/core/modules/auth/core-auth.module.js +4 -1
- package/dist/core/modules/auth/core-auth.module.js.map +1 -1
- package/dist/core/modules/auth/guards/legacy-auth-rate-limit.guard.d.ts +1 -1
- package/dist/core/modules/auth/guards/legacy-auth-rate-limit.guard.js +3 -14
- package/dist/core/modules/auth/guards/legacy-auth-rate-limit.guard.js.map +1 -1
- package/dist/core/modules/auth/services/legacy-auth-rate-limiter.service.d.ts +12 -10
- package/dist/core/modules/auth/services/legacy-auth-rate-limiter.service.js +30 -59
- package/dist/core/modules/auth/services/legacy-auth-rate-limiter.service.js.map +1 -1
- package/dist/core/modules/better-auth/core-better-auth-email-verification.service.d.ts +8 -2
- package/dist/core/modules/better-auth/core-better-auth-email-verification.service.js +118 -45
- package/dist/core/modules/better-auth/core-better-auth-email-verification.service.js.map +1 -1
- package/dist/core/modules/better-auth/core-better-auth-rate-limit.middleware.d.ts +1 -1
- package/dist/core/modules/better-auth/core-better-auth-rate-limit.middleware.js +2 -11
- package/dist/core/modules/better-auth/core-better-auth-rate-limit.middleware.js.map +1 -1
- package/dist/core/modules/better-auth/core-better-auth-rate-limiter.service.d.ts +12 -10
- package/dist/core/modules/better-auth/core-better-auth-rate-limiter.service.js +30 -85
- package/dist/core/modules/better-auth/core-better-auth-rate-limiter.service.js.map +1 -1
- package/dist/core/modules/better-auth/core-better-auth-user.mapper.js +0 -1
- package/dist/core/modules/better-auth/core-better-auth-user.mapper.js.map +1 -1
- package/dist/core/modules/file/core-file.controller.d.ts +15 -3
- package/dist/core/modules/file/core-file.controller.js +90 -20
- package/dist/core/modules/file/core-file.controller.js.map +1 -1
- package/dist/core/modules/file/core-file.resolver.d.ts +4 -4
- package/dist/core/modules/file/core-file.resolver.js +23 -16
- package/dist/core/modules/file/core-file.resolver.js.map +1 -1
- package/dist/core/modules/file/core-file.service.d.ts +41 -7
- package/dist/core/modules/file/core-file.service.js +227 -7
- package/dist/core/modules/file/core-file.service.js.map +1 -1
- package/dist/core/modules/file/file-metadata.helper.d.ts +16 -0
- package/dist/core/modules/file/file-metadata.helper.js +33 -0
- package/dist/core/modules/file/file-metadata.helper.js.map +1 -0
- package/dist/core/modules/file/file-roles.helper.d.ts +4 -0
- package/dist/core/modules/file/file-roles.helper.js +53 -0
- package/dist/core/modules/file/file-roles.helper.js.map +1 -0
- package/dist/core/modules/file/file-storage.helper.d.ts +13 -0
- package/dist/core/modules/file/file-storage.helper.js +65 -0
- package/dist/core/modules/file/file-storage.helper.js.map +1 -0
- package/dist/core/modules/file/filesystem-file.helper.d.ts +22 -0
- package/dist/core/modules/file/filesystem-file.helper.js +77 -0
- package/dist/core/modules/file/filesystem-file.helper.js.map +1 -0
- package/dist/core/modules/file/interfaces/file-service-options.interface.d.ts +1 -0
- package/dist/core/modules/file/interfaces/file-upload.interface.d.ts +7 -1
- package/dist/core/modules/file/s3-file.helper.d.ts +37 -0
- package/dist/core/modules/file/s3-file.helper.js +81 -0
- package/dist/core/modules/file/s3-file.helper.js.map +1 -0
- package/dist/core/modules/hub/core-hub.controller.d.ts +8 -8
- package/dist/core/modules/hub/core-hub.controller.js +24 -22
- package/dist/core/modules/hub/core-hub.controller.js.map +1 -1
- package/dist/core/modules/hub/helpers/hub-mask.helper.js +2 -2
- package/dist/core/modules/hub/helpers/hub-mask.helper.js.map +1 -1
- package/dist/core/modules/hub/hub-buffer.d.ts +23 -0
- package/dist/core/modules/hub/hub-buffer.js +101 -0
- package/dist/core/modules/hub/hub-buffer.js.map +1 -0
- package/dist/core/modules/hub/services/core-hub-mailbox.service.d.ts +7 -5
- package/dist/core/modules/hub/services/core-hub-mailbox.service.js +14 -10
- package/dist/core/modules/hub/services/core-hub-mailbox.service.js.map +1 -1
- package/dist/core/modules/hub/services/hub-log-buffer.service.d.ts +5 -2
- package/dist/core/modules/hub/services/hub-log-buffer.service.js +17 -8
- package/dist/core/modules/hub/services/hub-log-buffer.service.js.map +1 -1
- package/dist/core/modules/hub/services/hub-query-profiler.service.d.ts +4 -2
- package/dist/core/modules/hub/services/hub-query-profiler.service.js +13 -7
- package/dist/core/modules/hub/services/hub-query-profiler.service.js.map +1 -1
- package/dist/core/modules/hub/services/hub-trace-buffer.service.d.ts +4 -2
- package/dist/core/modules/hub/services/hub-trace-buffer.service.js +13 -8
- package/dist/core/modules/hub/services/hub-trace-buffer.service.js.map +1 -1
- package/dist/core/modules/migrate/cli/migrate-cli.d.ts +3 -1
- package/dist/core/modules/migrate/cli/migrate-cli.js +29 -4
- package/dist/core/modules/migrate/cli/migrate-cli.js.map +1 -1
- package/dist/core/modules/migrate/helpers/migration.helper.d.ts +3 -6
- package/dist/core/modules/migrate/helpers/migration.helper.js +55 -8
- package/dist/core/modules/migrate/helpers/migration.helper.js.map +1 -1
- package/dist/core/modules/migrate/migration-runner.d.ts +1 -0
- package/dist/core/modules/migrate/migration-runner.js +4 -0
- package/dist/core/modules/migrate/migration-runner.js.map +1 -1
- package/dist/core/modules/migrate/mongo-state-store.d.ts +1 -0
- package/dist/core/modules/migrate/mongo-state-store.js +98 -9
- package/dist/core/modules/migrate/mongo-state-store.js.map +1 -1
- package/dist/core/modules/system-setup/core-system-setup.service.d.ts +2 -0
- package/dist/core/modules/system-setup/core-system-setup.service.js +31 -1
- package/dist/core/modules/system-setup/core-system-setup.service.js.map +1 -1
- package/dist/core/modules/tenant/core-tenant.guard.d.ts +17 -3
- package/dist/core/modules/tenant/core-tenant.guard.js +92 -15
- package/dist/core/modules/tenant/core-tenant.guard.js.map +1 -1
- package/dist/core/modules/tenant/core-tenant.module.js +3 -4
- package/dist/core/modules/tenant/core-tenant.module.js.map +1 -1
- package/dist/core/modules/tus/core-tus.controller.d.ts +2 -0
- package/dist/core/modules/tus/core-tus.controller.js +29 -3
- package/dist/core/modules/tus/core-tus.controller.js.map +1 -1
- package/dist/core/modules/tus/core-tus.service.d.ts +24 -1
- package/dist/core/modules/tus/core-tus.service.js +182 -28
- package/dist/core/modules/tus/core-tus.service.js.map +1 -1
- package/dist/core/modules/tus/interfaces/tus-config.interface.js +3 -0
- package/dist/core/modules/tus/interfaces/tus-config.interface.js.map +1 -1
- package/dist/core/modules/tus/tus-redis-locker.d.ts +25 -0
- package/dist/core/modules/tus/tus-redis-locker.js +87 -0
- package/dist/core/modules/tus/tus-redis-locker.js.map +1 -0
- package/dist/core/modules/tus/tus.module.d.ts +1 -0
- package/dist/core/modules/tus/tus.module.js +30 -4
- package/dist/core/modules/tus/tus.module.js.map +1 -1
- package/dist/core.module.js +18 -1
- package/dist/core.module.js.map +1 -1
- package/dist/index.d.ts +10 -0
- package/dist/index.js +10 -0
- package/dist/index.js.map +1 -1
- package/dist/main.js +2 -1
- package/dist/main.js.map +1 -1
- package/dist/server/modules/file/file.controller.js.map +1 -1
- package/dist/server/modules/file/file.resolver.js +1 -12
- package/dist/server/modules/file/file.resolver.js.map +1 -1
- package/dist/server/modules/file/file.service.d.ts +5 -1
- package/dist/server/modules/file/file.service.js +12 -3
- package/dist/server/modules/file/file.service.js.map +1 -1
- package/dist/server/modules/user/avatar.controller.d.ts +5 -1
- package/dist/server/modules/user/avatar.controller.js +26 -10
- package/dist/server/modules/user/avatar.controller.js.map +1 -1
- package/dist/server/modules/user/user.model.js +3 -2
- package/dist/server/modules/user/user.model.js.map +1 -1
- package/dist/server/modules/user/user.module.js +6 -2
- package/dist/server/modules/user/user.module.js.map +1 -1
- package/dist/server/modules/user/user.service.d.ts +1 -1
- package/dist/server/modules/user/user.service.js +6 -13
- package/dist/server/modules/user/user.service.js.map +1 -1
- package/dist/tsconfig.build.tsbuildinfo +1 -1
- package/docs/REQUEST-LIFECYCLE.md +57 -8
- package/docs/security-overrides.md +30 -5
- package/migration-guides/11.32.3-to-11.32.4.md +335 -0
- package/migration-guides/11.32.x-to-11.33.x.md +1551 -0
- package/package.json +74 -42
- package/src/core/common/helpers/content-disposition.helper.ts +90 -0
- package/src/core/common/helpers/file.helper.ts +201 -20
- package/src/core/common/helpers/graceful-shutdown.helper.ts +116 -0
- package/src/core/common/helpers/project-name.helper.ts +71 -0
- package/src/core/common/interfaces/cron-job-config.interface.ts +16 -0
- package/src/core/common/interfaces/server-options.interface.ts +439 -2
- package/src/core/common/services/core-cron-jobs.initializer.ts +39 -0
- package/src/core/common/services/core-cron-jobs.registry.ts +45 -0
- package/src/core/common/services/core-cron-jobs.service.ts +589 -47
- package/src/core/common/services/core-redis-pubsub.ts +183 -0
- package/src/core/common/services/core-redis.service.ts +249 -0
- package/src/core/common/services/core-s3.service.ts +465 -0
- package/src/core/common/services/core-trust-proxy.initializer.ts +112 -0
- package/src/core/common/services/rate-limit-store.ts +492 -0
- package/src/core/modules/ai/README.md +25 -0
- package/src/core/modules/ai/core-ai-mcp.controller.ts +253 -22
- package/src/core/modules/ai/services/core-ai.service.ts +33 -24
- package/src/core/modules/auth/core-auth.module.ts +7 -1
- package/src/core/modules/auth/guards/legacy-auth-rate-limit.guard.ts +12 -21
- package/src/core/modules/auth/services/legacy-auth-rate-limiter.service.ts +49 -93
- package/src/core/modules/better-auth/core-better-auth-email-verification.service.ts +202 -66
- package/src/core/modules/better-auth/core-better-auth-rate-limit.middleware.ts +11 -18
- package/src/core/modules/better-auth/core-better-auth-rate-limiter.service.ts +51 -132
- package/src/core/modules/better-auth/core-better-auth-user.mapper.ts +7 -1
- package/src/core/modules/file/INTEGRATION-CHECKLIST.md +108 -0
- package/src/core/modules/file/README.md +324 -41
- package/src/core/modules/file/core-file.controller.ts +267 -19
- package/src/core/modules/file/core-file.resolver.ts +46 -12
- package/src/core/modules/file/core-file.service.ts +482 -16
- package/src/core/modules/file/file-metadata.helper.ts +142 -0
- package/src/core/modules/file/file-roles.helper.ts +110 -0
- package/src/core/modules/file/file-storage.helper.ts +163 -0
- package/src/core/modules/file/filesystem-file.helper.ts +184 -0
- package/src/core/modules/file/interfaces/file-service-options.interface.ts +14 -0
- package/src/core/modules/file/interfaces/file-upload.interface.ts +17 -1
- package/src/core/modules/file/s3-file.helper.ts +209 -0
- package/src/core/modules/hub/README.md +48 -0
- package/src/core/modules/hub/core-hub.controller.ts +18 -16
- package/src/core/modules/hub/helpers/hub-mask.helper.ts +21 -4
- package/src/core/modules/hub/hub-buffer.ts +198 -0
- package/src/core/modules/hub/services/core-hub-mailbox.service.ts +22 -12
- package/src/core/modules/hub/services/hub-log-buffer.service.ts +24 -8
- package/src/core/modules/hub/services/hub-query-profiler.service.ts +9 -6
- package/src/core/modules/hub/services/hub-trace-buffer.service.ts +14 -9
- package/src/core/modules/migrate/README.md +68 -6
- package/src/core/modules/migrate/cli/migrate-cli.ts +69 -6
- package/src/core/modules/migrate/helpers/migration.helper.ts +144 -10
- package/src/core/modules/migrate/migration-runner.ts +10 -1
- package/src/core/modules/migrate/mongo-state-store.ts +179 -11
- package/src/core/modules/system-setup/README.md +7 -2
- package/src/core/modules/system-setup/core-system-setup.service.ts +95 -1
- package/src/core/modules/tenant/README.md +21 -1
- package/src/core/modules/tenant/core-tenant.guard.ts +135 -14
- package/src/core/modules/tenant/core-tenant.module.ts +8 -4
- package/src/core/modules/tus/INTEGRATION-CHECKLIST.md +93 -21
- package/src/core/modules/tus/README.md +35 -2
- package/src/core/modules/tus/core-tus.controller.ts +64 -12
- package/src/core/modules/tus/core-tus.service.ts +358 -34
- package/src/core/modules/tus/interfaces/tus-config.interface.ts +9 -0
- package/src/core/modules/tus/tus-redis-locker.ts +139 -0
- package/src/core/modules/tus/tus.module.ts +78 -6
- package/src/core.module.ts +26 -1
- package/src/index.ts +10 -0
- package/src/main.ts +7 -1
- package/src/server/modules/file/file.controller.ts +7 -3
- package/src/server/modules/file/file.resolver.ts +5 -17
- package/src/server/modules/file/file.service.ts +42 -3
- package/src/server/modules/user/avatar.controller.ts +39 -9
- package/src/server/modules/user/user.model.ts +9 -3
- package/src/server/modules/user/user.module.ts +12 -3
- package/src/server/modules/user/user.service.ts +20 -17
- package/src/test/README.md +7 -2
- package/src/core/common/helpers/logging.helper.spec.ts +0 -61
- package/src/core/common/helpers/process-diagnostics.helper.spec.ts +0 -310
- package/src/core/common/services/brevo.service.spec.ts +0 -266
- package/src/core/modules/hub/core-hub.module.spec.ts +0 -108
- package/src/core/modules/hub/helpers/hub-command-shape.helper.spec.ts +0 -48
- package/src/core/modules/hub/helpers/hub-mask.helper.spec.ts +0 -67
- package/src/core/modules/hub/helpers/hub-mermaid.helper.spec.ts +0 -61
- package/src/core/modules/hub/helpers/hub-shell.helper.spec.ts +0 -106
- package/src/core/modules/hub/hub-config.helper.spec.ts +0 -108
- package/src/core/modules/hub/hub-ring-buffer.spec.ts +0 -95
- package/src/core/modules/hub/services/core-hub-mailbox.service.spec.ts +0 -116
- package/src/core/modules/hub/services/hub-trace-buffer.service.spec.ts +0 -112
|
@@ -1,310 +0,0 @@
|
|
|
1
|
-
import { EventEmitter } from 'node:events';
|
|
2
|
-
import { afterEach, describe, expect, it, vi } from 'vitest';
|
|
3
|
-
|
|
4
|
-
import { handleFatalBootstrapError, installProcessDiagnostics } from './process-diagnostics.helper';
|
|
5
|
-
|
|
6
|
-
// `vi.spyOn(fs, 'writeSync')` cannot work here — an ESM module namespace is not configurable, so
|
|
7
|
-
// the property cannot be redefined. Mocking the module is the only way to observe the default
|
|
8
|
-
// sink, and the helper imports nothing else from `node:fs`.
|
|
9
|
-
const { writeSyncMock } = vi.hoisted(() => ({ writeSyncMock: vi.fn<(fd: number, data: string) => number>() }));
|
|
10
|
-
vi.mock('node:fs', () => ({ writeSync: writeSyncMock }));
|
|
11
|
-
|
|
12
|
-
/**
|
|
13
|
-
* Regression guard for the "silent exit" class of failures: a Node API under a ts-node dev
|
|
14
|
-
* runner can die printing nothing but `app crashed` — no stacktrace, which reads like a
|
|
15
|
-
* product bug and costs long, misdirected debugging sessions. The diagnostics helper makes
|
|
16
|
-
* the exit reason visible — a rejected fire-and-forget promise no longer takes the server
|
|
17
|
-
* down, an uncaught exception is logged with a clear marker before it exits, and an EXTERNAL
|
|
18
|
-
* termination signal (another tool's pkill, `lt dev down`, an OS OOM SIGTERM, Ctrl-C) is
|
|
19
|
-
* logged as such instead of masquerading as an in-process crash.
|
|
20
|
-
*
|
|
21
|
-
* The installer is tested against an injected EventEmitter so the real process (and the
|
|
22
|
-
* Vitest runner it lives in) is never signalled or exited.
|
|
23
|
-
*
|
|
24
|
-
* Repo-wiring assertions (src/main.ts, src/index.ts, nodemon.json) deliberately live in
|
|
25
|
-
* `tests/unit/process-diagnostics-wiring.spec.ts`, NOT here: this file ships inside `src/core/`
|
|
26
|
-
* and is copied verbatim into vendor-mode consumer projects, where those repo-root paths do not
|
|
27
|
-
* exist. Keeping this spec free of `process.cwd()` is what makes it portable.
|
|
28
|
-
*/
|
|
29
|
-
function setup(
|
|
30
|
-
preRegister?: Partial<Record<string, () => void>>,
|
|
31
|
-
options?: Parameters<typeof installProcessDiagnostics>[0],
|
|
32
|
-
) {
|
|
33
|
-
const target = new EventEmitter();
|
|
34
|
-
target.setMaxListeners(50);
|
|
35
|
-
const errors: string[] = [];
|
|
36
|
-
const warnings: string[] = [];
|
|
37
|
-
const logger = {
|
|
38
|
-
error: (message: string) => errors.push(message),
|
|
39
|
-
warn: (message: string) => warnings.push(message),
|
|
40
|
-
};
|
|
41
|
-
const exit = vi.fn();
|
|
42
|
-
const reraise = vi.fn();
|
|
43
|
-
if (preRegister) {
|
|
44
|
-
for (const [event, fn] of Object.entries(preRegister)) {
|
|
45
|
-
target.on(event, fn);
|
|
46
|
-
}
|
|
47
|
-
}
|
|
48
|
-
installProcessDiagnostics({ exit, logger, reraise, target, ...options });
|
|
49
|
-
return { errors, exit, reraise, target, warnings };
|
|
50
|
-
}
|
|
51
|
-
|
|
52
|
-
afterEach(() => {
|
|
53
|
-
vi.restoreAllMocks();
|
|
54
|
-
vi.useRealTimers();
|
|
55
|
-
});
|
|
56
|
-
|
|
57
|
-
describe('installProcessDiagnostics', () => {
|
|
58
|
-
it('logs an unhandled rejection but keeps the process alive', () => {
|
|
59
|
-
const { exit, target, warnings } = setup();
|
|
60
|
-
target.emit('unhandledRejection', new Error('SMTP down'));
|
|
61
|
-
expect(warnings.some((line) => line.includes('[unhandledRejection]') && line.includes('SMTP down'))).toBe(true);
|
|
62
|
-
expect(exit).not.toHaveBeenCalled();
|
|
63
|
-
});
|
|
64
|
-
|
|
65
|
-
it('routes an unhandled rejection to the NON-blocking sink', () => {
|
|
66
|
-
// An unhandled rejection can fire once per request while the server keeps serving. A
|
|
67
|
-
// synchronous write would block the whole event loop until stderr drains, so this path must
|
|
68
|
-
// use `warn` (async) and never `error` (sync). Nothing is terminating, so there is no
|
|
69
|
-
// last-gasp guarantee worth blocking for.
|
|
70
|
-
const { errors, target, warnings } = setup();
|
|
71
|
-
target.emit('unhandledRejection', new Error('per-request failure'));
|
|
72
|
-
expect(warnings).toHaveLength(1);
|
|
73
|
-
expect(errors).toHaveLength(0);
|
|
74
|
-
});
|
|
75
|
-
|
|
76
|
-
it('formats a non-Error rejection reason via String()', () => {
|
|
77
|
-
const { target, warnings } = setup();
|
|
78
|
-
target.emit('unhandledRejection', 'plain string reason');
|
|
79
|
-
expect(warnings.some((line) => line.includes('[unhandledRejection]') && line.includes('plain string reason'))).toBe(
|
|
80
|
-
true,
|
|
81
|
-
);
|
|
82
|
-
});
|
|
83
|
-
|
|
84
|
-
it('exits on an unhandled rejection when onUnhandledRejection is "exit"', () => {
|
|
85
|
-
// Restores Node >= 15's own `--unhandled-rejections=throw` default for deployments that
|
|
86
|
-
// prefer a clean restart over serving from an unknown state.
|
|
87
|
-
const { errors, exit, target } = setup(undefined, { onUnhandledRejection: 'exit' });
|
|
88
|
-
target.emit('unhandledRejection', new Error('inconsistent state'));
|
|
89
|
-
expect(errors.some((line) => line.includes('[unhandledRejection]'))).toBe(true);
|
|
90
|
-
expect(exit).toHaveBeenCalledWith(1);
|
|
91
|
-
});
|
|
92
|
-
|
|
93
|
-
it('logs an uncaught exception with a marker and exits with code 1', () => {
|
|
94
|
-
const { errors, exit, target } = setup();
|
|
95
|
-
target.emit('uncaughtException', new Error('boom'));
|
|
96
|
-
expect(errors.some((line) => line.includes('[uncaughtException]') && line.includes('boom'))).toBe(true);
|
|
97
|
-
expect(exit).toHaveBeenCalledWith(1);
|
|
98
|
-
});
|
|
99
|
-
|
|
100
|
-
it('does NOT print the error message twice', () => {
|
|
101
|
-
// `error.stack` already begins with `${name}: ${message}`. Prefixing the message again put the
|
|
102
|
-
// same line in the output twice, which reads like two distinct failures.
|
|
103
|
-
const { errors, target } = setup();
|
|
104
|
-
const error = new Error('duplicated');
|
|
105
|
-
target.emit('uncaughtException', error);
|
|
106
|
-
const occurrences = errors[0].split('duplicated').length - 1;
|
|
107
|
-
expect(occurrences).toBe(1);
|
|
108
|
-
});
|
|
109
|
-
|
|
110
|
-
it('falls back to name + message when an Error carries no stack', () => {
|
|
111
|
-
const { errors, target } = setup();
|
|
112
|
-
const error = new Error('no stack here');
|
|
113
|
-
error.stack = undefined;
|
|
114
|
-
target.emit('uncaughtException', error);
|
|
115
|
-
expect(errors[0]).toContain('Error: no stack here');
|
|
116
|
-
});
|
|
117
|
-
|
|
118
|
-
it('does NOT register a process warning handler', () => {
|
|
119
|
-
// Node prints process warnings to stderr itself and adding a listener does not replace that —
|
|
120
|
-
// it appends a second, strictly worse copy (Node's own line carries the warning `name` and the
|
|
121
|
-
// `--trace-warnings` hint). Duplicating it doubles the volume on a path that fires during
|
|
122
|
-
// normal operation.
|
|
123
|
-
const { target } = setup();
|
|
124
|
-
expect(target.listenerCount('warning')).toBe(0);
|
|
125
|
-
});
|
|
126
|
-
|
|
127
|
-
it('logs a non-zero exit code', () => {
|
|
128
|
-
const { errors, target } = setup();
|
|
129
|
-
target.emit('exit', 143);
|
|
130
|
-
expect(errors.some((line) => line.includes('[exit]') && line.includes('143'))).toBe(true);
|
|
131
|
-
});
|
|
132
|
-
|
|
133
|
-
it('stays silent on a clean exit', () => {
|
|
134
|
-
// A clean exit is not a diagnostic. Logging it under every CLI script and every graceful
|
|
135
|
-
// shutdown trains readers to ignore the marker.
|
|
136
|
-
const { errors, target } = setup();
|
|
137
|
-
target.emit('exit', 0);
|
|
138
|
-
expect(errors).toHaveLength(0);
|
|
139
|
-
});
|
|
140
|
-
|
|
141
|
-
it('logs SIGTERM as external termination and re-raises it when it is the only listener', () => {
|
|
142
|
-
const { errors, exit, reraise, target } = setup();
|
|
143
|
-
target.emit('SIGTERM', 'SIGTERM');
|
|
144
|
-
expect(errors.some((line) => line.includes('[signal]') && line.includes('SIGTERM'))).toBe(true);
|
|
145
|
-
expect(reraise).toHaveBeenCalledWith('SIGTERM');
|
|
146
|
-
expect(exit).not.toHaveBeenCalled();
|
|
147
|
-
// The handler MUST remove itself before re-raising — otherwise the re-raised signal
|
|
148
|
-
// re-enters the same handler forever (infinite log-and-re-raise loop, never terminates).
|
|
149
|
-
expect(target.listenerCount('SIGTERM')).toBe(0);
|
|
150
|
-
});
|
|
151
|
-
|
|
152
|
-
it('logs SIGINT and re-raises it when it is the only listener', () => {
|
|
153
|
-
const { errors, reraise, target } = setup();
|
|
154
|
-
target.emit('SIGINT', 'SIGINT');
|
|
155
|
-
expect(errors.some((line) => line.includes('[signal]') && line.includes('SIGINT'))).toBe(true);
|
|
156
|
-
expect(reraise).toHaveBeenCalledWith('SIGINT');
|
|
157
|
-
expect(target.listenerCount('SIGINT')).toBe(0);
|
|
158
|
-
});
|
|
159
|
-
|
|
160
|
-
it.each(['SIGHUP', 'SIGQUIT'] as const)('labels %s as an external termination too', (signal) => {
|
|
161
|
-
// A closed terminal (SIGHUP) or a SIGQUIT from an orchestrator used to terminate unannotated,
|
|
162
|
-
// producing exactly the stackless death this helper exists to explain.
|
|
163
|
-
const { errors, reraise, target } = setup();
|
|
164
|
-
target.emit(signal, signal);
|
|
165
|
-
expect(errors.some((line) => line.includes('[signal]') && line.includes(signal))).toBe(true);
|
|
166
|
-
expect(reraise).toHaveBeenCalledWith(signal);
|
|
167
|
-
});
|
|
168
|
-
|
|
169
|
-
it('does NOT touch SIGUSR2', () => {
|
|
170
|
-
// nodemon restarts the app with SIGUSR2. Attaching a listener overrides its disposition, and
|
|
171
|
-
// labelling a restart is not worth changing how restarts behave.
|
|
172
|
-
const { target } = setup();
|
|
173
|
-
expect(target.listenerCount('SIGUSR2')).toBe(0);
|
|
174
|
-
});
|
|
175
|
-
|
|
176
|
-
it('does NOT re-raise a signal when a graceful-shutdown handler is also registered', () => {
|
|
177
|
-
// `app.enableShutdownHooks()` registers its own SIGTERM/SIGINT listener. Re-raising on top of
|
|
178
|
-
// it would kill the process mid-shutdown and defeat the graceful teardown.
|
|
179
|
-
const other = vi.fn();
|
|
180
|
-
const { errors, reraise, target } = setup({ SIGTERM: other }, { shutdownTimeoutMs: 0 });
|
|
181
|
-
target.emit('SIGTERM', 'SIGTERM');
|
|
182
|
-
expect(errors.some((line) => line.includes('[signal]'))).toBe(true);
|
|
183
|
-
expect(errors.some((line) => line.includes('another handler owns the shutdown'))).toBe(true);
|
|
184
|
-
expect(reraise).not.toHaveBeenCalled();
|
|
185
|
-
});
|
|
186
|
-
|
|
187
|
-
it('forces an exit when the co-listener never finishes the shutdown', () => {
|
|
188
|
-
// Abdicating unconditionally means a co-listener that never terminates makes SIGTERM a
|
|
189
|
-
// permanent no-op while the log line claims the process is going down — only SIGKILL would
|
|
190
|
-
// still work. The watchdog bounds that.
|
|
191
|
-
vi.useFakeTimers();
|
|
192
|
-
const { errors, exit, target } = setup({ SIGTERM: vi.fn() }, { shutdownTimeoutMs: 30_000 });
|
|
193
|
-
target.emit('SIGTERM', 'SIGTERM');
|
|
194
|
-
expect(exit).not.toHaveBeenCalled();
|
|
195
|
-
vi.advanceTimersByTime(30_000);
|
|
196
|
-
expect(errors.some((line) => line.includes('shutdown watchdog expired'))).toBe(true);
|
|
197
|
-
expect(exit).toHaveBeenCalledWith(1);
|
|
198
|
-
});
|
|
199
|
-
|
|
200
|
-
it('does not arm the watchdog when shutdownTimeoutMs is 0', () => {
|
|
201
|
-
vi.useFakeTimers();
|
|
202
|
-
const { exit, target } = setup({ SIGTERM: vi.fn() }, { shutdownTimeoutMs: 0 });
|
|
203
|
-
target.emit('SIGTERM', 'SIGTERM');
|
|
204
|
-
vi.advanceTimersByTime(120_000);
|
|
205
|
-
expect(exit).not.toHaveBeenCalled();
|
|
206
|
-
});
|
|
207
|
-
|
|
208
|
-
it('is idempotent — installing twice does not stack duplicate handlers', () => {
|
|
209
|
-
const target = new EventEmitter();
|
|
210
|
-
const logger = { error: () => undefined };
|
|
211
|
-
const exit = vi.fn();
|
|
212
|
-
const reraise = vi.fn();
|
|
213
|
-
installProcessDiagnostics({ exit, logger, reraise, target });
|
|
214
|
-
installProcessDiagnostics({ exit, logger, reraise, target });
|
|
215
|
-
expect(target.listenerCount('uncaughtException')).toBe(1);
|
|
216
|
-
expect(target.listenerCount('unhandledRejection')).toBe(1);
|
|
217
|
-
expect(target.listenerCount('SIGTERM')).toBe(1);
|
|
218
|
-
expect(target.listenerCount('SIGINT')).toBe(1);
|
|
219
|
-
});
|
|
220
|
-
|
|
221
|
-
it('falls back to the error sink when a custom logger provides no warn', () => {
|
|
222
|
-
const target = new EventEmitter();
|
|
223
|
-
const errors: string[] = [];
|
|
224
|
-
installProcessDiagnostics({
|
|
225
|
-
exit: vi.fn(),
|
|
226
|
-
logger: { error: (message: string) => errors.push(message) },
|
|
227
|
-
reraise: vi.fn(),
|
|
228
|
-
target,
|
|
229
|
-
});
|
|
230
|
-
target.emit('unhandledRejection', new Error('no warn sink'));
|
|
231
|
-
expect(errors.some((line) => line.includes('[unhandledRejection]'))).toBe(true);
|
|
232
|
-
});
|
|
233
|
-
});
|
|
234
|
-
|
|
235
|
-
describe('redaction and truncation', () => {
|
|
236
|
-
it('masks a JWT and truncates an oversized line', () => {
|
|
237
|
-
const target = new EventEmitter();
|
|
238
|
-
const warnings: string[] = [];
|
|
239
|
-
installProcessDiagnostics({
|
|
240
|
-
exit: vi.fn(),
|
|
241
|
-
logger: { error: () => undefined, warn: (message: string) => warnings.push(message) },
|
|
242
|
-
reraise: vi.fn(),
|
|
243
|
-
target,
|
|
244
|
-
});
|
|
245
|
-
const jwt = `eyJhbGciOiJIUzI1NiJ9.${'a'.repeat(40)}.${'b'.repeat(20)}`;
|
|
246
|
-
const error = new Error(`token=${jwt} ${'x'.repeat(30_000)}`);
|
|
247
|
-
error.stack = `Error: token=${jwt} ${'x'.repeat(30_000)}`;
|
|
248
|
-
target.emit('unhandledRejection', error);
|
|
249
|
-
|
|
250
|
-
expect(warnings).toHaveLength(1);
|
|
251
|
-
expect(warnings[0]).not.toContain(jwt);
|
|
252
|
-
expect(warnings[0]).toContain('[truncated]');
|
|
253
|
-
expect(warnings[0].length).toBeLessThan(20_000);
|
|
254
|
-
});
|
|
255
|
-
});
|
|
256
|
-
|
|
257
|
-
describe('defaultDiagnosticsLogger', () => {
|
|
258
|
-
it('writes synchronously to fd 2 so a last-gasp line cannot be truncated', () => {
|
|
259
|
-
// console.error is asynchronous on a pipe, so a line written immediately before process.exit()
|
|
260
|
-
// can be dropped — silently losing the very line this helper exists to emit.
|
|
261
|
-
writeSyncMock.mockReset();
|
|
262
|
-
writeSyncMock.mockReturnValue(0);
|
|
263
|
-
const target = new EventEmitter();
|
|
264
|
-
installProcessDiagnostics({ exit: vi.fn(), reraise: vi.fn(), target });
|
|
265
|
-
target.emit('uncaughtException', new Error('sync sink'));
|
|
266
|
-
|
|
267
|
-
expect(writeSyncMock).toHaveBeenCalled();
|
|
268
|
-
const [fd, payload] = writeSyncMock.mock.calls[0];
|
|
269
|
-
expect(fd).toBe(2);
|
|
270
|
-
expect(String(payload)).toContain('[uncaughtException]');
|
|
271
|
-
});
|
|
272
|
-
|
|
273
|
-
it('survives a broken stderr instead of escalating', () => {
|
|
274
|
-
// EBADF (fd 2 closed), EPIPE (reader exited) and EAGAIN (non-blocking pipe) all make writeSync
|
|
275
|
-
// throw. A throw raised INSIDE the uncaughtException handler makes Node exit 7 and print its
|
|
276
|
-
// own "throw inside handler" message — losing the original error entirely.
|
|
277
|
-
writeSyncMock.mockReset();
|
|
278
|
-
writeSyncMock.mockImplementation(() => {
|
|
279
|
-
throw Object.assign(new Error('EBADF: bad file descriptor'), { code: 'EBADF' });
|
|
280
|
-
});
|
|
281
|
-
const exit = vi.fn();
|
|
282
|
-
const target = new EventEmitter();
|
|
283
|
-
installProcessDiagnostics({ exit, reraise: vi.fn(), target });
|
|
284
|
-
|
|
285
|
-
expect(() => target.emit('uncaughtException', new Error('original failure'))).not.toThrow();
|
|
286
|
-
expect(exit).toHaveBeenCalledWith(1);
|
|
287
|
-
});
|
|
288
|
-
});
|
|
289
|
-
|
|
290
|
-
describe('handleFatalBootstrapError', () => {
|
|
291
|
-
it('logs a failed bootstrap with a marker and exits with code 1', () => {
|
|
292
|
-
const errors: string[] = [];
|
|
293
|
-
const logger = { error: (message: string) => errors.push(message) };
|
|
294
|
-
const exit = vi.fn();
|
|
295
|
-
handleFatalBootstrapError(new Error('listen EADDRINUSE: address already in use'), { exit, logger });
|
|
296
|
-
expect(errors.some((line) => line.includes('[bootstrap]') && line.includes('EADDRINUSE'))).toBe(true);
|
|
297
|
-
expect(exit).toHaveBeenCalledWith(1);
|
|
298
|
-
});
|
|
299
|
-
|
|
300
|
-
it('redacts secrets out of a startup error before logging it', () => {
|
|
301
|
-
// The documented motivating failures ("DB unreachable") carry connection strings.
|
|
302
|
-
const errors: string[] = [];
|
|
303
|
-
const exit = vi.fn();
|
|
304
|
-
handleFatalBootstrapError(new Error('connect failed: password=hunter2'), {
|
|
305
|
-
exit,
|
|
306
|
-
logger: { error: (message: string) => errors.push(message) },
|
|
307
|
-
});
|
|
308
|
-
expect(errors[0]).not.toContain('hunter2');
|
|
309
|
-
});
|
|
310
|
-
});
|
|
@@ -1,266 +0,0 @@
|
|
|
1
|
-
import { Logger } from '@nestjs/common';
|
|
2
|
-
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest';
|
|
3
|
-
|
|
4
|
-
import type { Brevo, BrevoClient as RealBrevoClient } from '@getbrevo/brevo';
|
|
5
|
-
|
|
6
|
-
import { BrevoService } from './brevo.service';
|
|
7
|
-
import type { ConfigService } from './config.service';
|
|
8
|
-
|
|
9
|
-
/**
|
|
10
|
-
* Type-level contract against the REAL SDK.
|
|
11
|
-
*
|
|
12
|
-
* Everything below runs against a hand-written mock, so the mock alone could keep passing after a
|
|
13
|
-
* breaking SDK upgrade. `sendMail()` is declared `Promise<unknown>`, which gives tsc nothing to
|
|
14
|
-
* check either. These assertions close that gap: they compile against the installed
|
|
15
|
-
* `@getbrevo/brevo`, and `pnpm run typecheck:tests` covers `src/**\/*.spec.ts`, so a v7 that
|
|
16
|
-
* re-introduces a `.body` envelope or renames the request type fails the build here rather than
|
|
17
|
-
* silently at runtime.
|
|
18
|
-
*/
|
|
19
|
-
type SendFn = RealBrevoClient['transactionalEmails']['sendTransacEmail'];
|
|
20
|
-
/** Awaiting the call must yield the payload itself — v3's `{ response, body }` envelope is gone. */
|
|
21
|
-
type AssertNoEnvelope = Awaited<ReturnType<SendFn>> extends { body: unknown } ? never : true;
|
|
22
|
-
/** The request type the service builds must still be assignable to the SDK's parameter. */
|
|
23
|
-
type AssertRequestType = Brevo.SendTransacEmailRequest extends NonNullable<Parameters<SendFn>[0]> ? true : never;
|
|
24
|
-
// Consumed by an assertion below so the contract cannot be dead-code-eliminated or linted away.
|
|
25
|
-
const sdkContract: [AssertNoEnvelope, AssertRequestType] = [true, true];
|
|
26
|
-
|
|
27
|
-
/**
|
|
28
|
-
* Shared handles into the mocked SDK. `vi.hoisted` runs before the `vi.mock` factory below, so the
|
|
29
|
-
* spies exist by the time the module graph is wired up.
|
|
30
|
-
*/
|
|
31
|
-
const brevoMock = vi.hoisted(() => ({
|
|
32
|
-
/** Every options object the client was constructed with, in call order. */
|
|
33
|
-
clientOptions: [] as unknown[],
|
|
34
|
-
sendTransacEmail: vi.fn(),
|
|
35
|
-
}));
|
|
36
|
-
|
|
37
|
-
vi.mock('@getbrevo/brevo', () => ({
|
|
38
|
-
BrevoClient: class {
|
|
39
|
-
transactionalEmails = { sendTransacEmail: brevoMock.sendTransacEmail };
|
|
40
|
-
|
|
41
|
-
constructor(options: unknown) {
|
|
42
|
-
brevoMock.clientOptions.push(options);
|
|
43
|
-
}
|
|
44
|
-
},
|
|
45
|
-
}));
|
|
46
|
-
|
|
47
|
-
const API_KEY = 'test-api-key';
|
|
48
|
-
const SENDER = { email: 'noreply@test.com', name: 'Test Sender' };
|
|
49
|
-
|
|
50
|
-
/** Matches the per-send `Idempotency-Key` header without pinning the random UUID. */
|
|
51
|
-
const anyIdempotencyHeaders = { 'Idempotency-Key': expect.any(String) as unknown as string };
|
|
52
|
-
|
|
53
|
-
/**
|
|
54
|
-
* Minimal ConfigService double.
|
|
55
|
-
*
|
|
56
|
-
* `exclude` can be set independently on the mutable (`config`) and the frozen
|
|
57
|
-
* (`configFastButReadOnly`) side so the tests can prove which one the service reads.
|
|
58
|
-
*/
|
|
59
|
-
function makeConfigService(
|
|
60
|
-
options: {
|
|
61
|
-
exclude?: RegExp;
|
|
62
|
-
frozenExclude?: RegExp;
|
|
63
|
-
maxRetries?: number;
|
|
64
|
-
throwOnError?: boolean;
|
|
65
|
-
timeoutInSeconds?: number;
|
|
66
|
-
withBrevo?: boolean;
|
|
67
|
-
} = {},
|
|
68
|
-
): ConfigService {
|
|
69
|
-
const { exclude, frozenExclude = exclude, maxRetries, throwOnError, timeoutInSeconds, withBrevo = true } = options;
|
|
70
|
-
const base = { apiKey: API_KEY, maxRetries, sender: SENDER, throwOnError, timeoutInSeconds };
|
|
71
|
-
return {
|
|
72
|
-
config: withBrevo ? { brevo: { ...base, exclude } } : {},
|
|
73
|
-
configFastButReadOnly: withBrevo ? { brevo: { ...base, exclude: frozenExclude } } : {},
|
|
74
|
-
} as unknown as ConfigService;
|
|
75
|
-
}
|
|
76
|
-
|
|
77
|
-
describe('@getbrevo/brevo SDK contract', () => {
|
|
78
|
-
it('still resolves the payload directly and accepts the request type we build', () => {
|
|
79
|
-
// The assertion is the COMPILATION of `sdkContract` above, which `pnpm run typecheck:tests`
|
|
80
|
-
// performs against the really installed SDK. This test body exists so the contract is also
|
|
81
|
-
// referenced at runtime — otherwise it reads as dead code and invites deletion.
|
|
82
|
-
expect(sdkContract).toEqual([true, true]);
|
|
83
|
-
});
|
|
84
|
-
});
|
|
85
|
-
|
|
86
|
-
describe('BrevoService', () => {
|
|
87
|
-
let loggerError: ReturnType<typeof vi.spyOn>;
|
|
88
|
-
|
|
89
|
-
beforeEach(() => {
|
|
90
|
-
brevoMock.sendTransacEmail.mockReset();
|
|
91
|
-
brevoMock.clientOptions.length = 0;
|
|
92
|
-
loggerError = vi.spyOn(Logger.prototype, 'error').mockImplementation(() => undefined);
|
|
93
|
-
});
|
|
94
|
-
|
|
95
|
-
afterEach(() => {
|
|
96
|
-
// The spies above are never restored otherwise. File-level isolation contains it today, but a
|
|
97
|
-
// leaked Logger spy is the kind of thing that only surfaces as an unrelated flake later.
|
|
98
|
-
vi.restoreAllMocks();
|
|
99
|
-
});
|
|
100
|
-
|
|
101
|
-
describe('constructor', () => {
|
|
102
|
-
it('throws when the Brevo configuration is missing', () => {
|
|
103
|
-
expect(() => new BrevoService(makeConfigService({ withBrevo: false }))).toThrow('Brevo configuration not set!');
|
|
104
|
-
});
|
|
105
|
-
|
|
106
|
-
it('does NOT construct the SDK client eagerly', async () => {
|
|
107
|
-
// The SDK is ~580 CommonJS modules and BrevoService is re-exported from the package barrel,
|
|
108
|
-
// so a static import would put that cost on every consumer's cold start — including the
|
|
109
|
-
// majority that never configure Brevo.
|
|
110
|
-
const service = new BrevoService(makeConfigService());
|
|
111
|
-
expect(brevoMock.clientOptions).toEqual([]);
|
|
112
|
-
|
|
113
|
-
brevoMock.sendTransacEmail.mockResolvedValue({ messageId: '<x@brevo>' });
|
|
114
|
-
await service.sendMail('user@example.com', 42);
|
|
115
|
-
expect(brevoMock.clientOptions).toHaveLength(1);
|
|
116
|
-
});
|
|
117
|
-
|
|
118
|
-
it('creates the client with the configured API key and safe request limits', async () => {
|
|
119
|
-
brevoMock.sendTransacEmail.mockResolvedValue({ messageId: '<x@brevo>' });
|
|
120
|
-
const service = new BrevoService(makeConfigService());
|
|
121
|
-
await service.sendMail('user@example.com', 42);
|
|
122
|
-
|
|
123
|
-
// The SDK defaults to 2 retries honouring `Retry-After` with a 60 s cap PER attempt and to
|
|
124
|
-
// no timeout at all. Both send methods are awaited inside request handlers, so those
|
|
125
|
-
// defaults would let a rate-limited Brevo park a user-facing request for ~2 minutes.
|
|
126
|
-
expect(brevoMock.clientOptions).toEqual([{ apiKey: API_KEY, maxRetries: 0, timeoutInSeconds: 10 }]);
|
|
127
|
-
});
|
|
128
|
-
|
|
129
|
-
it('honours configured retry and timeout overrides', async () => {
|
|
130
|
-
brevoMock.sendTransacEmail.mockResolvedValue({ messageId: '<x@brevo>' });
|
|
131
|
-
const service = new BrevoService(makeConfigService({ maxRetries: 3, timeoutInSeconds: 30 }));
|
|
132
|
-
await service.sendMail('user@example.com', 42);
|
|
133
|
-
expect(brevoMock.clientOptions).toEqual([{ apiKey: API_KEY, maxRetries: 3, timeoutInSeconds: 30 }]);
|
|
134
|
-
});
|
|
135
|
-
|
|
136
|
-
it('reuses the client across sends', async () => {
|
|
137
|
-
brevoMock.sendTransacEmail.mockResolvedValue({ messageId: '<x@brevo>' });
|
|
138
|
-
const service = new BrevoService(makeConfigService());
|
|
139
|
-
await service.sendMail('user@example.com', 42);
|
|
140
|
-
await service.sendMail('other@example.com', 42);
|
|
141
|
-
expect(brevoMock.clientOptions).toHaveLength(1);
|
|
142
|
-
});
|
|
143
|
-
});
|
|
144
|
-
|
|
145
|
-
describe('sendMail', () => {
|
|
146
|
-
it('rejects incomplete input without calling the API', async () => {
|
|
147
|
-
const service = new BrevoService(makeConfigService());
|
|
148
|
-
await expect(service.sendMail('', 42)).resolves.toBe(false);
|
|
149
|
-
await expect(service.sendMail('user@example.com', 0)).resolves.toBe(false);
|
|
150
|
-
expect(brevoMock.sendTransacEmail).not.toHaveBeenCalled();
|
|
151
|
-
});
|
|
152
|
-
|
|
153
|
-
it('sends template, recipient and params, and returns the response unwrapped', async () => {
|
|
154
|
-
const response = { messageId: '<mail-1@brevo>' };
|
|
155
|
-
brevoMock.sendTransacEmail.mockResolvedValue(response);
|
|
156
|
-
const service = new BrevoService(makeConfigService());
|
|
157
|
-
|
|
158
|
-
// Identity check: v6 resolves the payload directly, there is no `.body` envelope any more
|
|
159
|
-
await expect(service.sendMail('user@example.com', 42, { name: 'Test' })).resolves.toBe(response);
|
|
160
|
-
expect(brevoMock.sendTransacEmail).toHaveBeenCalledWith({
|
|
161
|
-
headers: anyIdempotencyHeaders,
|
|
162
|
-
params: { name: 'Test' },
|
|
163
|
-
templateId: 42,
|
|
164
|
-
to: [{ email: 'user@example.com' }],
|
|
165
|
-
});
|
|
166
|
-
});
|
|
167
|
-
|
|
168
|
-
it('sends a unique Idempotency-Key per call', async () => {
|
|
169
|
-
// The SDK retries POSTs on 408/429/5xx. Without a key, a retry issued after a delivered
|
|
170
|
-
// response whose reply was lost sends the mail twice.
|
|
171
|
-
brevoMock.sendTransacEmail.mockResolvedValue({ messageId: '<x@brevo>' });
|
|
172
|
-
const service = new BrevoService(makeConfigService());
|
|
173
|
-
await service.sendMail('user@example.com', 42);
|
|
174
|
-
await service.sendMail('user@example.com', 42);
|
|
175
|
-
|
|
176
|
-
const keys = brevoMock.sendTransacEmail.mock.calls.map(
|
|
177
|
-
([request]) => (request as Brevo.SendTransacEmailRequest).headers?.['Idempotency-Key'],
|
|
178
|
-
);
|
|
179
|
-
expect(keys[0]).toBeTypeOf('string');
|
|
180
|
-
expect(keys[0]).not.toBe(keys[1]);
|
|
181
|
-
});
|
|
182
|
-
|
|
183
|
-
it('skips excluded (test) recipients', async () => {
|
|
184
|
-
const service = new BrevoService(makeConfigService({ exclude: /@test\.com$/i }));
|
|
185
|
-
await expect(service.sendMail('user@test.com', 42)).resolves.toBe('TEST_USER!');
|
|
186
|
-
expect(brevoMock.sendTransacEmail).not.toHaveBeenCalled();
|
|
187
|
-
});
|
|
188
|
-
|
|
189
|
-
it('reads the exclude pattern from the mutable config, not the frozen one', async () => {
|
|
190
|
-
// A frozen RegExp carrying the `g` flag throws on `.test()` (it assigns `lastIndex`).
|
|
191
|
-
// Reading `exclude` off `configFastButReadOnly` would therefore fail instead of excluding.
|
|
192
|
-
const service = new BrevoService(
|
|
193
|
-
makeConfigService({ exclude: /@test\.com$/i, frozenExclude: Object.freeze(/@test\.com$/gi) }),
|
|
194
|
-
);
|
|
195
|
-
await expect(service.sendMail('user@test.com', 42)).resolves.toBe('TEST_USER!');
|
|
196
|
-
});
|
|
197
|
-
|
|
198
|
-
it('returns null when the API call fails', async () => {
|
|
199
|
-
brevoMock.sendTransacEmail.mockRejectedValue(new Error('Brevo down'));
|
|
200
|
-
const service = new BrevoService(makeConfigService());
|
|
201
|
-
await expect(service.sendMail('user@example.com', 42)).resolves.toBeNull();
|
|
202
|
-
});
|
|
203
|
-
|
|
204
|
-
it('logs the failure through the Nest logger', async () => {
|
|
205
|
-
// The whole point of the sibling diagnostics work is that silent failures cost debugging
|
|
206
|
-
// sessions. Asserting only the `null` return would let the observability half regress.
|
|
207
|
-
brevoMock.sendTransacEmail.mockRejectedValue(new Error('Brevo down'));
|
|
208
|
-
const service = new BrevoService(makeConfigService());
|
|
209
|
-
await service.sendMail('user@example.com', 42);
|
|
210
|
-
expect(loggerError).toHaveBeenCalledWith(expect.stringContaining('Brevo down'));
|
|
211
|
-
});
|
|
212
|
-
|
|
213
|
-
it('rethrows when throwOnError is enabled', async () => {
|
|
214
|
-
const service = new BrevoService(makeConfigService({ throwOnError: true }));
|
|
215
|
-
brevoMock.sendTransacEmail.mockRejectedValue(new Error('Brevo down'));
|
|
216
|
-
await expect(service.sendMail('user@example.com', 42)).rejects.toThrow('Brevo down');
|
|
217
|
-
});
|
|
218
|
-
});
|
|
219
|
-
|
|
220
|
-
describe('sendHtmlMail', () => {
|
|
221
|
-
it('rejects incomplete input without calling the API', async () => {
|
|
222
|
-
const service = new BrevoService(makeConfigService());
|
|
223
|
-
await expect(service.sendHtmlMail('', 'Subject', '<p>Hi</p>')).resolves.toBe(false);
|
|
224
|
-
await expect(service.sendHtmlMail('user@example.com', '', '<p>Hi</p>')).resolves.toBe(false);
|
|
225
|
-
await expect(service.sendHtmlMail('user@example.com', 'Subject', '')).resolves.toBe(false);
|
|
226
|
-
expect(brevoMock.sendTransacEmail).not.toHaveBeenCalled();
|
|
227
|
-
});
|
|
228
|
-
|
|
229
|
-
it('sends html, subject and the configured sender, and returns the response unwrapped', async () => {
|
|
230
|
-
const response = { messageId: '<mail-2@brevo>' };
|
|
231
|
-
brevoMock.sendTransacEmail.mockResolvedValue(response);
|
|
232
|
-
const service = new BrevoService(makeConfigService());
|
|
233
|
-
|
|
234
|
-
await expect(
|
|
235
|
-
service.sendHtmlMail('user@example.com', 'Subject', '<p>Hi</p>', { params: { code: '123' } }),
|
|
236
|
-
).resolves.toBe(response);
|
|
237
|
-
expect(brevoMock.sendTransacEmail).toHaveBeenCalledWith({
|
|
238
|
-
headers: anyIdempotencyHeaders,
|
|
239
|
-
htmlContent: '<p>Hi</p>',
|
|
240
|
-
params: { code: '123' },
|
|
241
|
-
sender: SENDER,
|
|
242
|
-
subject: 'Subject',
|
|
243
|
-
to: [{ email: 'user@example.com' }],
|
|
244
|
-
});
|
|
245
|
-
});
|
|
246
|
-
|
|
247
|
-
it('skips excluded (test) recipients', async () => {
|
|
248
|
-
const service = new BrevoService(makeConfigService({ exclude: /@test\.com$/i }));
|
|
249
|
-
await expect(service.sendHtmlMail('user@test.com', 'Subject', '<p>Hi</p>')).resolves.toBe('TEST_USER!');
|
|
250
|
-
expect(brevoMock.sendTransacEmail).not.toHaveBeenCalled();
|
|
251
|
-
});
|
|
252
|
-
|
|
253
|
-
it('returns null when the API call fails', async () => {
|
|
254
|
-
brevoMock.sendTransacEmail.mockRejectedValue(new Error('Brevo down'));
|
|
255
|
-
const service = new BrevoService(makeConfigService());
|
|
256
|
-
await expect(service.sendHtmlMail('user@example.com', 'Subject', '<p>Hi</p>')).resolves.toBeNull();
|
|
257
|
-
});
|
|
258
|
-
|
|
259
|
-
it('logs the failure through the Nest logger', async () => {
|
|
260
|
-
brevoMock.sendTransacEmail.mockRejectedValue(new Error('Brevo down'));
|
|
261
|
-
const service = new BrevoService(makeConfigService());
|
|
262
|
-
await service.sendHtmlMail('user@example.com', 'Subject', '<p>Hi</p>');
|
|
263
|
-
expect(loggerError).toHaveBeenCalledWith(expect.stringContaining('Brevo down'));
|
|
264
|
-
});
|
|
265
|
-
});
|
|
266
|
-
});
|