@lenne.tech/nest-server 11.31.3 → 11.32.1

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.
Files changed (37) hide show
  1. package/.claude/rules/versioning.md +5 -8
  2. package/CLAUDE.md +3 -3
  3. package/FRAMEWORK-API.md +4 -2
  4. package/README.md +1 -0
  5. package/dist/core/common/helpers/process-diagnostics.helper.d.ts +18 -0
  6. package/dist/core/common/helpers/process-diagnostics.helper.js +88 -0
  7. package/dist/core/common/helpers/process-diagnostics.helper.js.map +1 -0
  8. package/dist/core/common/interfaces/server-options.interface.d.ts +3 -0
  9. package/dist/core/common/services/brevo.service.d.ts +7 -1
  10. package/dist/core/common/services/brevo.service.js +37 -16
  11. package/dist/core/common/services/brevo.service.js.map +1 -1
  12. package/dist/core/modules/better-auth/core-better-auth-email-verification.service.js +9 -4
  13. package/dist/core/modules/better-auth/core-better-auth-email-verification.service.js.map +1 -1
  14. package/dist/core/modules/migrate/migration-runner.js +4 -0
  15. package/dist/core/modules/migrate/migration-runner.js.map +1 -1
  16. package/dist/index.d.ts +1 -0
  17. package/dist/index.js +1 -0
  18. package/dist/index.js.map +1 -1
  19. package/dist/main.js +5 -2
  20. package/dist/main.js.map +1 -1
  21. package/dist/tsconfig.build.tsbuildinfo +1 -1
  22. package/docs/REQUEST-LIFECYCLE.md +1 -0
  23. package/docs/brevo-manual-test.md +166 -0
  24. package/docs/security-overrides.md +90 -0
  25. package/migration-guides/11.31.3-to-11.32.0.md +254 -0
  26. package/migration-guides/11.32.0-to-11.32.1.md +84 -0
  27. package/package.json +15 -14
  28. package/src/core/common/helpers/process-diagnostics.helper.spec.ts +310 -0
  29. package/src/core/common/helpers/process-diagnostics.helper.ts +321 -0
  30. package/src/core/common/interfaces/server-options.interface.ts +32 -0
  31. package/src/core/common/services/brevo.service.spec.ts +266 -0
  32. package/src/core/common/services/brevo.service.ts +100 -17
  33. package/src/core/modules/better-auth/core-better-auth-email-verification.service.ts +14 -4
  34. package/src/core/modules/hub/helpers/hub-mermaid.helper.spec.ts +8 -1
  35. package/src/core/modules/migrate/migration-runner.ts +17 -0
  36. package/src/index.ts +1 -0
  37. package/src/main.ts +22 -3
@@ -0,0 +1,321 @@
1
+ /**
2
+ * Process-level exit diagnostics.
3
+ *
4
+ * A Node API can die "silently": the dev runner (`nodemon`, `lt dev`) prints only
5
+ * `app crashed - waiting for file changes before starting...` with NO stacktrace, and in a
6
+ * container the process simply vanishes. A missing stacktrace means the process was killed by a
7
+ * SIGNAL rather than throwing — either an OS OOM SIGKILL (uncatchable) or an EXTERNAL
8
+ * SIGTERM/SIGINT (an orchestrator's stop, another tool's pkill, `lt dev down`, Ctrl-C).
9
+ *
10
+ * `installProcessDiagnostics()` makes the exit reason visible:
11
+ * - `unhandledRejection` is logged but NOT fatal by default — a rejected fire-and-forget promise
12
+ * (e.g. a transactional email failing on an auth path outside a try/catch) must never take the
13
+ * whole server down. Set `onUnhandledRejection: 'exit'` to restore Node's own fail-fast.
14
+ * - `uncaughtException` is logged with a clear marker and then exits(1) — process state is unknown
15
+ * after one, so crashing and being restarted is the safe behaviour, but with a guaranteed log
16
+ * line above the runner's "app crashed".
17
+ * - a NON-ZERO `exit` code is logged (a clean exit needs no explanation and no extra line).
18
+ * - termination signals are logged as EXTERNAL terminations so they stop masquerading as
19
+ * in-process crashes.
20
+ *
21
+ * **Deliberately NOT handled: process `warning`s.** Node prints them to stderr itself, and adding a
22
+ * listener does not replace that — it appends a second, strictly worse copy (Node's own line
23
+ * carries the warning `name` and the `--trace-warnings` hint; ours would not). Duplicating it would
24
+ * double the volume on the one path that fires during normal operation. Use `--trace-warnings`
25
+ * instead when a warning needs a stack.
26
+ *
27
+ * **Call it from `main.ts`, not from a module.** It is deliberately NOT wired into
28
+ * `CoreModule.forRoot()`:
29
+ * 1. It must run BEFORE `NestFactory.create()` to catch failures during module construction —
30
+ * inside a module it would already be too late.
31
+ * 2. It installs a `process.exit(1)` path. Registering that from a module would also arm it inside
32
+ * `Test.createTestingModule()`, where an uncaught exception would then kill the whole test
33
+ * runner instead of failing one spec.
34
+ *
35
+ * **Known limitation:** it cannot cover failures during synchronous *import* evaluation of
36
+ * `main.ts` (a throwing `config.env.ts`, an SWC temporal-dead-zone `ReferenceError`), because
37
+ * imports are hoisted above the first statement of `bootstrap()`. That class of failure is loud
38
+ * anyway — Node prints it with a full stack. Only a `--require` preload module could cover it.
39
+ *
40
+ * **Pair it with `enableShutdownHooks()`.** On its own this helper labels a signal; it does not
41
+ * make the process terminate on one. In a container Node is PID 1, where a userspace re-raise with
42
+ * the default disposition is silently discarded by the kernel — the event loop stays busy and
43
+ * `docker stop` waits out its grace period before SIGKILL. `app.enableShutdownHooks()` is what
44
+ * actually drains the loop, and this helper then correctly defers to it.
45
+ *
46
+ * @example
47
+ * ```typescript
48
+ * // src/main.ts
49
+ * import { handleFatalBootstrapError, installProcessDiagnostics } from '@lenne.tech/nest-server';
50
+ *
51
+ * async function bootstrap() {
52
+ * installProcessDiagnostics();
53
+ * const server = await NestFactory.create<NestExpressApplication>(ServerModule);
54
+ * server.enableShutdownHooks(); // required for a graceful container stop
55
+ * await server.listen(envConfig.port);
56
+ * }
57
+ *
58
+ * // A rejection here is a fatal startup failure — exit instead of leaving a zombie process.
59
+ * bootstrap().catch(handleFatalBootstrapError);
60
+ * ```
61
+ */
62
+
63
+ import { writeSync } from 'node:fs';
64
+
65
+ import { redactSensitiveText } from './logging.helper';
66
+
67
+ /**
68
+ * Hard cap for a single diagnostic line.
69
+ *
70
+ * `describeError` renders a caller-controlled value — a rejected promise can carry an object whose
71
+ * `toString()` returns megabytes. One unbounded synchronous write would stall the event loop for
72
+ * its whole duration.
73
+ */
74
+ const MAX_DIAGNOSTIC_CHARS = 16_384;
75
+
76
+ /** Minimal logger surface — `console` satisfies it. */
77
+ export interface DiagnosticsLogger {
78
+ /**
79
+ * Sink for TERMINAL diagnostics (uncaught exception, non-zero exit, signals, fatal bootstrap).
80
+ * These are written immediately before the process goes away, so the default implementation is
81
+ * synchronous.
82
+ */
83
+ error: (message: string) => void;
84
+ /**
85
+ * Sink for NON-TERMINAL diagnostics (an unhandled rejection while the server keeps serving).
86
+ * Defaults to `error` when omitted. The default implementation is ASYNCHRONOUS on purpose — see
87
+ * {@link defaultDiagnosticsLogger}.
88
+ */
89
+ warn?: (message: string) => void;
90
+ }
91
+
92
+ /**
93
+ * Caps and redacts a diagnostic line before it reaches any sink.
94
+ *
95
+ * Redaction matters because this sink deliberately bypasses the Nest logger, and with it every
96
+ * structured-log redaction a consumer has configured. The framework's own `redactSensitiveText`
97
+ * is the same one the Hub log buffer uses, so a connection string or bearer token in an error
98
+ * message is masked here exactly as it would be there.
99
+ *
100
+ * @param message - The raw diagnostic line
101
+ * @returns The line, truncated to {@link MAX_DIAGNOSTIC_CHARS} and redacted
102
+ */
103
+ function formatDiagnostic(message: string): string {
104
+ // Cap BEFORE redacting: only the truncated head is ever emitted, so redacting the discarded
105
+ // tail would be wasted work on the very path that must stay cheap.
106
+ const capped =
107
+ message.length > MAX_DIAGNOSTIC_CHARS ? `${message.slice(0, MAX_DIAGNOSTIC_CHARS)}… [truncated]` : message;
108
+ return redactSensitiveText(capped);
109
+ }
110
+
111
+ /**
112
+ * Default diagnostic sink.
113
+ *
114
+ * `error` is a SYNCHRONOUS write to fd 2 (stderr). `console.error` is asynchronous on a pipe, so a
115
+ * last-gasp line written immediately before `process.exit()` or a signal re-raise can be truncated
116
+ * — silently dropping the very line this helper exists to emit. `writeSync` completes before we
117
+ * terminate.
118
+ *
119
+ * `warn` is deliberately ASYNCHRONOUS. A synchronous write blocks the whole event loop until the
120
+ * pipe drains, so on the one path that fires while the server is still serving — an unhandled
121
+ * rejection, potentially once per request — a stalled log collector would stall the entire process.
122
+ * Nothing is about to terminate there, so there is no last-gasp guarantee to preserve.
123
+ *
124
+ * Both bypass the Nest logger (including any JSON logger configured for production): these lines
125
+ * must survive the exact moments when the DI container is being torn down or is already gone.
126
+ * Structured-log consumers see them as plain stderr records. Pass a custom `logger` if they must be
127
+ * routed elsewhere.
128
+ */
129
+ const defaultDiagnosticsLogger: DiagnosticsLogger = {
130
+ error: (message: string) => {
131
+ try {
132
+ writeSync(2, `${message}\n`);
133
+ } catch {
134
+ // EBADF (fd 2 closed), EPIPE (reader exited — routine under `| head` or a detached log
135
+ // driver), EAGAIN (non-blocking pipe under backpressure). A diagnostic line is best-effort:
136
+ // throwing here would escalate. Worse, a throw raised INSIDE the uncaughtException handler
137
+ // makes Node exit 7 and print its own "throw inside handler" message instead of the original
138
+ // error — inverting the entire purpose of this helper.
139
+ }
140
+ },
141
+ warn: (message: string) => {
142
+ console.error(message);
143
+ },
144
+ };
145
+
146
+ /** Options for {@link installProcessDiagnostics}. All dependencies are injectable for testing. */
147
+ export interface ProcessDiagnosticsOptions {
148
+ /** Process-exit function. Defaults to `process.exit`. Injected in tests. */
149
+ exit?: (code: number) => void;
150
+ /** Logger for the diagnostic lines. Defaults to a synchronous stderr sink. */
151
+ logger?: DiagnosticsLogger;
152
+ /**
153
+ * What to do on an unhandled rejection.
154
+ *
155
+ * - `'log'` (default): log and continue — a fire-and-forget failure must not take the server down.
156
+ * - `'exit'`: log and `exit(1)`, restoring Node >= 15's own `--unhandled-rejections=throw`
157
+ * default for deployments that prefer a clean restart over serving from an unknown state.
158
+ *
159
+ * @default 'log'
160
+ */
161
+ onUnhandledRejection?: 'exit' | 'log';
162
+ /** Re-raise a signal with its default disposition. Defaults to `process.kill(process.pid, signal)`. */
163
+ reraise?: (signal: NodeJS.Signals) => void;
164
+ /**
165
+ * How long to wait for another signal handler (e.g. `enableShutdownHooks()`) to finish the
166
+ * shutdown before forcing an exit. `0` disables the watchdog.
167
+ *
168
+ * Without it, a co-listener that never terminates makes SIGTERM a permanent no-op while the log
169
+ * line claims the process is going down — only SIGKILL would still work.
170
+ *
171
+ * @default 30000
172
+ */
173
+ shutdownTimeoutMs?: number;
174
+ /** Event target to attach handlers to. Defaults to the global `process`. Injected in tests. */
175
+ target?: NodeJS.EventEmitter;
176
+ }
177
+
178
+ /**
179
+ * Signals whose default disposition is to terminate the process.
180
+ *
181
+ * `SIGUSR2` is deliberately absent: nodemon uses it to trigger a restart, and attaching a listener
182
+ * overrides its disposition. Labelling a restart is not worth the risk of changing how it behaves.
183
+ */
184
+ const TERMINATION_SIGNALS: readonly NodeJS.Signals[] = ['SIGTERM', 'SIGINT', 'SIGHUP', 'SIGQUIT'];
185
+
186
+ /** Guards against double-installation (e.g. an accidental second bootstrap call). */
187
+ const installedTargets = new WeakSet<object>();
188
+
189
+ /**
190
+ * Renders any thrown value as a loggable string, preserving the stack when there is one.
191
+ *
192
+ * @param value - The thrown / rejected value (not necessarily an `Error`)
193
+ * @returns The stack for an `Error` (it already begins with `name: message`), `String(value)` otherwise
194
+ */
195
+ function describeError(value: unknown): string {
196
+ if (value instanceof Error) {
197
+ // `stack` already starts with `${name}: ${message}` — prefixing the message again would print
198
+ // it twice. Fall back to that same shape when a stack is missing.
199
+ return value.stack ?? `${value.name}: ${value.message}`;
200
+ }
201
+ return String(value);
202
+ }
203
+
204
+ /**
205
+ * Attaches the process-level diagnostic handlers. Idempotent per target — calling it twice does
206
+ * not stack duplicate listeners.
207
+ *
208
+ * Call this as the first statement of `bootstrap()`, before `NestFactory.create()`. See the module
209
+ * docblock for why it must not live inside `CoreModule.forRoot()`.
210
+ *
211
+ * @param options - Injectable dependencies; defaults target the real `process`
212
+ *
213
+ * @example
214
+ * installProcessDiagnostics();
215
+ * installProcessDiagnostics({ onUnhandledRejection: 'exit' }); // restore Node's fail-fast
216
+ */
217
+ export function installProcessDiagnostics(options: ProcessDiagnosticsOptions = {}): void {
218
+ const target: NodeJS.EventEmitter = options.target ?? process;
219
+ const logger: DiagnosticsLogger = options.logger ?? defaultDiagnosticsLogger;
220
+ const exit: (code: number) => void = options.exit ?? ((code: number) => process.exit(code));
221
+ const reraise: (signal: NodeJS.Signals) => void =
222
+ options.reraise ?? ((signal: NodeJS.Signals) => process.kill(process.pid, signal));
223
+ const onUnhandledRejection: 'exit' | 'log' = options.onUnhandledRejection ?? 'log';
224
+ const shutdownTimeoutMs: number = options.shutdownTimeoutMs ?? 30_000;
225
+
226
+ // Non-terminal lines go through `warn` (async, cannot block the event loop); everything else
227
+ // through `error` (sync, survives teardown). A custom logger may omit `warn` — then it opts into
228
+ // its own `error` for both.
229
+ const logTerminal = (message: string): void => logger.error(formatDiagnostic(message));
230
+ const logNonTerminal = (message: string): void => (logger.warn ?? logger.error)(formatDiagnostic(message));
231
+
232
+ if (installedTargets.has(target)) {
233
+ return;
234
+ }
235
+ installedTargets.add(target);
236
+
237
+ target.on('unhandledRejection', (reason: unknown) => {
238
+ if (onUnhandledRejection === 'exit') {
239
+ logTerminal(`[unhandledRejection] ${describeError(reason)}`);
240
+ exit(1);
241
+ return;
242
+ }
243
+ logNonTerminal(`[unhandledRejection] ${describeError(reason)}`);
244
+ });
245
+
246
+ target.on('uncaughtException', (error: unknown) => {
247
+ logTerminal(`[uncaughtException] ${describeError(error)}`);
248
+ exit(1);
249
+ });
250
+
251
+ target.on('exit', (code: unknown) => {
252
+ // A clean exit is not a diagnostic. Logging it unconditionally puts a line under every CLI
253
+ // script and every graceful shutdown, which trains readers to ignore the marker.
254
+ if (code !== 0) {
255
+ logTerminal(`[exit] process exiting with code ${String(code)}`);
256
+ }
257
+ });
258
+
259
+ for (const signal of TERMINATION_SIGNALS) {
260
+ const handler = (received: NodeJS.Signals): void => {
261
+ // Attaching a listener overrode Node's default terminate disposition. When we are the only
262
+ // listener, re-raise the signal so the process still terminates as it would have. If another
263
+ // handler is also registered (e.g. `app.enableShutdownHooks()`), let it own the exit.
264
+ const alone = target.listenerCount(signal) <= 1;
265
+ logTerminal(
266
+ `[signal] received ${received} — external termination (not an in-process crash)${
267
+ alone ? '' : ' — another handler owns the shutdown'
268
+ }`,
269
+ );
270
+
271
+ if (alone) {
272
+ target.removeListener(signal, handler);
273
+ reraise(received);
274
+ return;
275
+ }
276
+
277
+ if (shutdownTimeoutMs > 0) {
278
+ // The co-listener owns the exit — but it may never reach one. Do not stay hostage forever:
279
+ // an unkillable-by-SIGTERM process is worse than an ungraceful one.
280
+ const watchdog = setTimeout(() => {
281
+ logTerminal(`[signal] shutdown watchdog expired after ${shutdownTimeoutMs}ms — forcing exit`);
282
+ exit(1);
283
+ }, shutdownTimeoutMs);
284
+ // Never keep the event loop alive purely for the watchdog.
285
+ watchdog.unref?.();
286
+ }
287
+ };
288
+ target.on(signal, handler);
289
+ }
290
+ }
291
+
292
+ /** Options for {@link handleFatalBootstrapError}. All dependencies are injectable for testing. */
293
+ export interface FatalBootstrapErrorOptions {
294
+ /** Process-exit function. Defaults to `process.exit`. Injected in tests. */
295
+ exit?: (code: number) => void;
296
+ /** Logger for the diagnostic line. Defaults to a synchronous stderr sink. */
297
+ logger?: DiagnosticsLogger;
298
+ }
299
+
300
+ /**
301
+ * Terminal handler for a failed server bootstrap. Use it as the `catch` of the fire-and-forget
302
+ * `bootstrap()` call in `main.ts`.
303
+ *
304
+ * Without it, a rejection from `bootstrap()` (e.g. `server.listen` failing with EADDRINUSE, or a
305
+ * MongoDB connection error) surfaces as a mere `unhandledRejection` — which the runtime guard logs
306
+ * but does NOT act on, leaving a ZOMBIE process that is "alive" but listening on nothing. A startup
307
+ * failure is fatal: log it loudly and exit so the supervisor (nodemon in dev, the container runtime
308
+ * in production) restarts a clean instance.
309
+ *
310
+ * @param error - The rejection value from `bootstrap()`
311
+ * @param options - Injectable dependencies; defaults exit the real process
312
+ *
313
+ * @example
314
+ * bootstrap().catch(handleFatalBootstrapError);
315
+ */
316
+ export function handleFatalBootstrapError(error: unknown, options: FatalBootstrapErrorOptions = {}): void {
317
+ const logger: DiagnosticsLogger = options.logger ?? defaultDiagnosticsLogger;
318
+ const exit: (code: number) => void = options.exit ?? ((code: number) => process.exit(code));
319
+ logger.error(formatDiagnostic(`[bootstrap] fatal startup error — exiting: ${describeError(error)}`));
320
+ exit(1);
321
+ }
@@ -1520,6 +1520,19 @@ export interface IServerOptions {
1520
1520
  */
1521
1521
  exclude?: RegExp;
1522
1522
 
1523
+ /**
1524
+ * How often the SDK retries a failed send (408 / 429 / 5xx).
1525
+ *
1526
+ * The Brevo SDK defaults to 2 retries and honours `Retry-After` with a cap of 60 SECONDS per
1527
+ * attempt. `BrevoService.sendMail()` is awaited inside request handlers (e.g. the BetterAuth
1528
+ * email-verification hook), so a rate-limited Brevo can otherwise park a user-facing HTTP
1529
+ * request for roughly two minutes. The framework default of `0` keeps the request fast and
1530
+ * leaves retrying to the caller.
1531
+ *
1532
+ * @default 0
1533
+ */
1534
+ maxRetries?: number;
1535
+
1523
1536
  /**
1524
1537
  * Default sender for Brevo
1525
1538
  */
@@ -1527,6 +1540,25 @@ export interface IServerOptions {
1527
1540
  email: string;
1528
1541
  name: string;
1529
1542
  };
1543
+
1544
+ /**
1545
+ * Whether a failed send should throw instead of resolving to `null`.
1546
+ *
1547
+ * `false` (default) preserves the historical contract: errors are logged and the method
1548
+ * resolves to `null`. Set `true` when the caller must not proceed on a silent failure — the
1549
+ * framework's own email-verification path checks the `null` either way.
1550
+ *
1551
+ * @default false
1552
+ */
1553
+ throwOnError?: boolean;
1554
+
1555
+ /**
1556
+ * Per-request timeout in seconds. Unset means no timeout — a hung connection is then bounded
1557
+ * only by the HTTP agent's own (300 s) limits, multiplied by `maxRetries`.
1558
+ *
1559
+ * @default 10
1560
+ */
1561
+ timeoutInSeconds?: number;
1530
1562
  };
1531
1563
 
1532
1564
  /**
@@ -0,0 +1,266 @@
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
+ });