te.js 2.1.5 → 2.2.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.
Files changed (49) hide show
  1. package/auto-docs/analysis/handler-analyzer.test.js +106 -0
  2. package/auto-docs/analysis/source-resolver.test.js +58 -0
  3. package/auto-docs/constants.js +13 -2
  4. package/auto-docs/openapi/generator.js +7 -5
  5. package/auto-docs/openapi/generator.test.js +132 -0
  6. package/auto-docs/openapi/spec-builders.js +39 -19
  7. package/cli/docs-command.js +44 -36
  8. package/cors/index.test.js +82 -0
  9. package/database/index.js +3 -1
  10. package/database/mongodb.js +17 -11
  11. package/database/redis.js +53 -44
  12. package/docs/configuration.md +24 -10
  13. package/docs/error-handling.md +134 -50
  14. package/lib/llm/client.js +40 -10
  15. package/lib/llm/index.js +14 -1
  16. package/lib/llm/parse.test.js +60 -0
  17. package/package.json +3 -1
  18. package/radar/index.js +281 -0
  19. package/rate-limit/index.js +8 -11
  20. package/rate-limit/index.test.js +64 -0
  21. package/server/ammo/body-parser.js +156 -152
  22. package/server/ammo/body-parser.test.js +79 -0
  23. package/server/ammo/enhancer.js +8 -4
  24. package/server/ammo.js +216 -17
  25. package/server/context/request-context.js +51 -0
  26. package/server/context/request-context.test.js +53 -0
  27. package/server/endpoint.js +15 -0
  28. package/server/error.js +56 -3
  29. package/server/error.test.js +45 -0
  30. package/server/errors/channels/base.js +31 -0
  31. package/server/errors/channels/channels.test.js +148 -0
  32. package/server/errors/channels/console.js +64 -0
  33. package/server/errors/channels/index.js +111 -0
  34. package/server/errors/channels/log.js +27 -0
  35. package/server/errors/llm-cache.js +102 -0
  36. package/server/errors/llm-cache.test.js +160 -0
  37. package/server/errors/llm-error-service.js +77 -16
  38. package/server/errors/llm-rate-limiter.js +72 -0
  39. package/server/errors/llm-rate-limiter.test.js +105 -0
  40. package/server/files/uploader.js +38 -26
  41. package/server/handler.js +5 -3
  42. package/server/targets/registry.js +9 -9
  43. package/server/targets/registry.test.js +108 -0
  44. package/te.js +214 -57
  45. package/utils/auto-register.js +1 -1
  46. package/utils/configuration.js +23 -9
  47. package/utils/configuration.test.js +58 -0
  48. package/utils/errors-llm-config.js +142 -9
  49. package/utils/request-logger.js +49 -3
package/te.js CHANGED
@@ -3,6 +3,7 @@ import { env, setEnv } from 'tej-env';
3
3
  import TejLogger from 'tej-logger';
4
4
  import rateLimiter from './rate-limit/index.js';
5
5
  import corsMiddleware from './cors/index.js';
6
+ import radarMiddleware from './radar/index.js';
6
7
 
7
8
  import targetRegistry from './server/targets/registry.js';
8
9
  import dbManager from './database/index.js';
@@ -10,15 +11,59 @@ import dbManager from './database/index.js';
10
11
  import { loadConfigFile, standardizeObj } from './utils/configuration.js';
11
12
 
12
13
  import targetHandler from './server/handler.js';
13
- import { getErrorsLlmConfig, validateErrorsLlmAtTakeoff } from './utils/errors-llm-config.js';
14
+ import {
15
+ getErrorsLlmConfig,
16
+ validateErrorsLlmAtTakeoff,
17
+ } from './utils/errors-llm-config.js';
14
18
  import path from 'node:path';
15
19
  import { pathToFileURL } from 'node:url';
16
20
  import { readFile } from 'node:fs/promises';
17
21
  import { findTargetFiles } from './utils/auto-register.js';
18
22
  import { registerDocRoutes } from './auto-docs/ui/docs-ui.js';
23
+ import TejError from './server/error.js';
19
24
 
20
25
  const logger = new TejLogger('Tejas');
21
26
 
27
+ /**
28
+ * Performs a graceful shutdown: closes the HTTP server (if started), then exits.
29
+ * Invoked by process-level fatal error handlers.
30
+ * @param {number} [exitCode=1]
31
+ */
32
+ async function gracefulShutdown(exitCode = 1) {
33
+ const instance = Tejas.instance;
34
+ if (instance?.engine) {
35
+ try {
36
+ await new Promise((resolve) => instance.engine.close(resolve));
37
+ } catch {
38
+ // ignore close errors during shutdown
39
+ }
40
+ }
41
+ process.exit(exitCode);
42
+ }
43
+
44
+ process.on('unhandledRejection', (reason) => {
45
+ process.stderr.write(
46
+ JSON.stringify({
47
+ level: 'fatal',
48
+ code: 'ERR_UNHANDLED_REJECTION',
49
+ reason: String(reason),
50
+ }) + '\n',
51
+ );
52
+ gracefulShutdown(1);
53
+ });
54
+
55
+ process.on('uncaughtException', (error) => {
56
+ process.stderr.write(
57
+ JSON.stringify({
58
+ level: 'fatal',
59
+ code: 'ERR_UNCAUGHT_EXCEPTION',
60
+ message: error.message,
61
+ stack: error.stack,
62
+ }) + '\n',
63
+ );
64
+ gracefulShutdown(1);
65
+ });
66
+
22
67
  /**
23
68
  * Main Tejas Framework Class
24
69
  *
@@ -49,17 +94,14 @@ class Tejas {
49
94
  constructor(args) {
50
95
  if (Tejas.instance) return Tejas.instance;
51
96
  Tejas.instance = this;
52
-
53
97
  this.options = args || {};
54
-
55
- this.generateConfiguration();
56
- this.registerTargetsDir();
57
98
  }
58
99
 
59
100
  /**
60
101
  * Generates and loads configuration from multiple sources
61
102
  *
62
103
  * @private
104
+ * @returns {Promise<void>}
63
105
  * @description
64
106
  * Loads and merges configuration from:
65
107
  * 1. tejas.config.json file (lowest priority)
@@ -69,15 +111,15 @@ class Tejas {
69
111
  * All configuration keys are standardized to uppercase and flattened.
70
112
  * Sets default values for required configuration if not provided.
71
113
  */
72
- generateConfiguration() {
73
- const configVars = standardizeObj(loadConfigFile());
114
+ async generateConfiguration() {
115
+ const configVars = standardizeObj(await loadConfigFile());
74
116
  const envVars = standardizeObj(process.env);
75
117
  const userVars = standardizeObj(this.options);
76
118
 
77
- const config = { ...configVars, ...envVars, ...userVars };
119
+ const config = Object.freeze({ ...configVars, ...envVars, ...userVars });
78
120
 
79
121
  for (const key in config) {
80
- if (config.hasOwnProperty(key)) {
122
+ if (Object.hasOwn(config, key)) {
81
123
  setEnv(key, config[key]);
82
124
  }
83
125
  }
@@ -86,6 +128,16 @@ class Tejas {
86
128
  if (!env('PORT')) setEnv('PORT', 1403);
87
129
  if (!env('BODY_MAX_SIZE')) setEnv('BODY_MAX_SIZE', 10 * 1024 * 1024); // 10MB default
88
130
  if (!env('BODY_TIMEOUT')) setEnv('BODY_TIMEOUT', 30000); // 30 seconds default
131
+
132
+ // Validate port is a usable integer
133
+ const port = Number(env('PORT'));
134
+ if (!Number.isInteger(port) || port < 1 || port > 65535) {
135
+ throw new TejError(
136
+ 500,
137
+ `Invalid PORT: "${env('PORT')}" — must be an integer between 1 and 65535`,
138
+ { cause: new Error(`ERR_CONFIG_INVALID`) },
139
+ );
140
+ }
89
141
  }
90
142
 
91
143
  /**
@@ -111,52 +163,41 @@ class Tejas {
111
163
  }
112
164
 
113
165
  /**
114
- * Automatically registers target files from the configured directory
166
+ * Automatically registers target files from the configured directory.
167
+ * Returns a Promise so takeoff() can await it — ensures all targets are
168
+ * fully loaded before the server starts accepting connections.
115
169
  *
116
170
  * @private
117
- * @description
118
- * Searches for and registers all files ending in 'target.js' from the
119
- * directory specified by DIR_TARGETS environment variable.
120
- * Target files define routes and their handlers.
121
- *
171
+ * @returns {Promise<void>}
122
172
  * @throws {Error} If target files cannot be registered
123
173
  */
124
- registerTargetsDir() {
174
+ async registerTargetsDir() {
125
175
  const baseDir = path.join(process.cwd(), process.env.DIR_TARGETS || '');
126
- findTargetFiles()
127
- .then((targetFiles) => {
128
- if (!targetFiles?.length) return;
129
- (async () => {
130
- for (const file of targetFiles) {
131
- const parentPath = file.path || '';
132
- const fullPath = path.isAbsolute(parentPath)
133
- ? path.join(parentPath, file.name)
134
- : path.join(baseDir, parentPath, file.name);
135
- const relativePath = path.relative(baseDir, fullPath);
136
- const groupId = relativePath
137
- .replace(/\.target\.js$/i, '')
138
- .replace(/\\/g, '/')
139
- || 'index';
140
- targetRegistry.setCurrentSourceGroup(groupId);
141
- try {
142
- await import(pathToFileURL(fullPath).href);
143
- } finally {
144
- targetRegistry.setCurrentSourceGroup(null);
145
- }
146
- }
147
- })().catch((err) => {
148
- logger.error(
149
- `Tejas could not register target files. Error: ${err}`,
150
- false,
151
- );
152
- });
153
- })
154
- .catch((err) => {
155
- logger.error(
156
- `Tejas could not register target files. Error: ${err}`,
157
- false,
158
- );
159
- });
176
+ try {
177
+ const targetFiles = await findTargetFiles();
178
+ if (!targetFiles?.length) return;
179
+ for (const file of targetFiles) {
180
+ const parentPath = file.path || '';
181
+ const fullPath = path.isAbsolute(parentPath)
182
+ ? path.join(parentPath, file.name)
183
+ : path.join(baseDir, parentPath, file.name);
184
+ const relativePath = path.relative(baseDir, fullPath);
185
+ const groupId =
186
+ relativePath.replace(/\.target\.js$/i, '').replace(/\\/g, '/') ||
187
+ 'index';
188
+ targetRegistry.setCurrentSourceGroup(groupId);
189
+ try {
190
+ await import(pathToFileURL(fullPath).href);
191
+ } finally {
192
+ targetRegistry.setCurrentSourceGroup(null);
193
+ }
194
+ }
195
+ } catch (err) {
196
+ logger.error(
197
+ `Tejas could not register target files. Error: ${err}`,
198
+ false,
199
+ );
200
+ }
160
201
  }
161
202
 
162
203
  /**
@@ -191,7 +232,10 @@ class Tejas {
191
232
  * // Start server without databases
192
233
  * app.takeoff(); // Server starts on default port 1403
193
234
  */
194
- takeoff({ withRedis, withMongo } = {}) {
235
+ async takeoff({ withRedis, withMongo } = {}) {
236
+ // Load configuration first (async file read)
237
+ await this.generateConfiguration();
238
+
195
239
  validateErrorsLlmAtTakeoff();
196
240
  const errorsLlm = getErrorsLlmConfig();
197
241
  if (errorsLlm.enabled) {
@@ -199,6 +243,11 @@ class Tejas {
199
243
  `errors.llm enabled successfully — baseURL: ${errorsLlm.baseURL}, model: ${errorsLlm.model}, messageType: ${errorsLlm.messageType}, apiKey: ${errorsLlm.apiKey ? '***' : '(missing)'}`,
200
244
  );
201
245
  }
246
+
247
+ // Register target files before the server starts listening so no request
248
+ // can arrive before all routes are fully registered.
249
+ await this.registerTargetsDir();
250
+
202
251
  this.engine = createServer(targetHandler);
203
252
  this.engine.listen(env('PORT'), async () => {
204
253
  logger.info(`Took off from port ${env('PORT')}`);
@@ -305,14 +354,21 @@ class Tejas {
305
354
 
306
355
  /**
307
356
  * Enables LLM-inferred error codes and messages for ammo.throw() and framework-caught errors.
308
- * Call before takeoff(). Remaining options (baseURL, apiKey, model, messageType) can come from
309
- * config, or from env/tejas.config.json (LLM_* / ERRORS_LLM_*). Validation runs at takeoff.
357
+ * Call before takeoff(). Remaining options can come from env/tejas.config.json (LLM_* / ERRORS_LLM_*).
358
+ * Validation runs at takeoff.
310
359
  *
311
360
  * @param {Object} [config] - Optional errors.llm overrides
312
361
  * @param {string} [config.baseURL] - LLM provider endpoint (e.g. https://api.openai.com/v1)
313
362
  * @param {string} [config.apiKey] - LLM provider API key
314
363
  * @param {string} [config.model] - Model name (e.g. gpt-4o-mini)
315
364
  * @param {'endUser'|'developer'} [config.messageType] - Default message tone
365
+ * @param {'sync'|'async'} [config.mode] - 'sync' blocks the response until LLM returns (default); 'async' responds immediately with 500 and dispatches LLM result to a channel
366
+ * @param {number} [config.timeout] - LLM fetch timeout in milliseconds (default 10000)
367
+ * @param {'console'|'log'|'both'} [config.channel] - Output channel for async mode results (default 'console')
368
+ * @param {string} [config.logFile] - Path to JSONL log file used by 'log' and 'both' channels (default './errors.llm.log')
369
+ * @param {number} [config.rateLimit] - Max LLM calls per minute across all requests (default 10)
370
+ * @param {boolean} [config.cache] - Cache LLM results by throw site + error message to avoid repeated calls (default true)
371
+ * @param {number} [config.cacheTTL] - How long cached results are reused in milliseconds (default 3600000 = 1 hour)
316
372
  * @returns {Tejas} The Tejas instance for chaining
317
373
  *
318
374
  * @example
@@ -322,6 +378,10 @@ class Tejas {
322
378
  * @example
323
379
  * app.withLLMErrors({ baseURL: 'https://api.openai.com/v1', apiKey: process.env.OPENAI_KEY, model: 'gpt-4o-mini' });
324
380
  * app.takeoff();
381
+ *
382
+ * @example
383
+ * app.withLLMErrors({ mode: 'async', channel: 'both', rateLimit: 20 });
384
+ * app.takeoff();
325
385
  */
326
386
  withLLMErrors(config) {
327
387
  setEnv('ERRORS_LLM_ENABLED', true);
@@ -329,7 +389,17 @@ class Tejas {
329
389
  if (config.baseURL != null) setEnv('ERRORS_LLM_BASE_URL', config.baseURL);
330
390
  if (config.apiKey != null) setEnv('ERRORS_LLM_API_KEY', config.apiKey);
331
391
  if (config.model != null) setEnv('ERRORS_LLM_MODEL', config.model);
332
- if (config.messageType != null) setEnv('ERRORS_LLM_MESSAGE_TYPE', config.messageType);
392
+ if (config.messageType != null)
393
+ setEnv('ERRORS_LLM_MESSAGE_TYPE', config.messageType);
394
+ if (config.mode != null) setEnv('ERRORS_LLM_MODE', config.mode);
395
+ if (config.timeout != null) setEnv('ERRORS_LLM_TIMEOUT', config.timeout);
396
+ if (config.channel != null) setEnv('ERRORS_LLM_CHANNEL', config.channel);
397
+ if (config.logFile != null) setEnv('ERRORS_LLM_LOG_FILE', config.logFile);
398
+ if (config.rateLimit != null)
399
+ setEnv('ERRORS_LLM_RATE_LIMIT', config.rateLimit);
400
+ if (config.cache != null) setEnv('ERRORS_LLM_CACHE', config.cache);
401
+ if (config.cacheTTL != null)
402
+ setEnv('ERRORS_LLM_CACHE_TTL', config.cacheTTL);
333
403
  }
334
404
  return this;
335
405
  }
@@ -385,6 +455,82 @@ class Tejas {
385
455
  return this;
386
456
  }
387
457
 
458
+ /**
459
+ * Enables Tejas Radar telemetry — captures HTTP request metrics and forwards
460
+ * them to a Radar collector for real-time observability.
461
+ *
462
+ * All options fall back to environment variables and sensible defaults, so
463
+ * the minimum viable call is just `app.withRadar({ apiKey: 'rdr_xxx' })`.
464
+ * The project name is auto-detected from `package.json` if not supplied.
465
+ *
466
+ * @param {Object} [config] - Radar configuration
467
+ * @param {string} [config.collectorUrl] Collector base URL (default: RADAR_COLLECTOR_URL env or http://localhost:3100)
468
+ * @param {string} [config.apiKey] Bearer token `rdr_xxx` (default: RADAR_API_KEY env)
469
+ * @param {string} [config.projectName] Project identifier (default: RADAR_PROJECT_NAME env → package.json name → "tejas-app")
470
+ * @param {number} [config.flushInterval] Milliseconds between periodic flushes (default: 2000)
471
+ * @param {number} [config.batchSize] Flush immediately when batch reaches this size (default: 100)
472
+ * @param {string[]} [config.ignore] Request paths to skip (default: ['/health'])
473
+ *
474
+ * @param {Object} [config.capture] Controls what additional data is captured and sent to the collector.
475
+ * All capture options default to `false` — nothing beyond standard
476
+ * metrics is sent unless explicitly enabled.
477
+ * @param {boolean} [config.capture.request=false]
478
+ * Capture and send the request body. The body is a shallow copy of parsed
479
+ * query params and request body fields merged together. Only JSON-serialisable
480
+ * content is sent. The collector applies non-bypassable GDPR field masking
481
+ * server-side regardless of this setting.
482
+ * @param {boolean} [config.capture.response=false]
483
+ * Capture and send the response body. The response must be valid JSON;
484
+ * non-JSON responses are recorded as `null`. The collector applies
485
+ * non-bypassable GDPR field masking server-side.
486
+ * @param {boolean|string[]} [config.capture.headers=false]
487
+ * Capture request headers. Pass `true` to send all headers, or a `string[]`
488
+ * allowlist of specific header names to send (e.g. `['content-type', 'x-request-id']`).
489
+ * The collector always strips sensitive headers (`authorization`, `cookie`,
490
+ * `set-cookie`, `x-api-key`, etc.) server-side regardless of what is sent.
491
+ *
492
+ * @param {Object} [config.mask] Client-side masking applied to request/response bodies
493
+ * before data is sent to the collector.
494
+ * @param {string[]} [config.mask.fields] Extra field names (case-insensitive) to mask client-side.
495
+ * Matched field values are replaced with `"*"` before leaving
496
+ * the process. Use this for application-specific sensitive fields
497
+ * that are not on the collector's built-in GDPR blocklist.
498
+ * Note: the collector enforces its own non-bypassable masking
499
+ * layer server-side regardless of this setting.
500
+ *
501
+ * @returns {Tejas} The Tejas instance for chaining
502
+ *
503
+ * @example
504
+ * app.withRadar({ apiKey: process.env.RADAR_API_KEY });
505
+ * app.takeoff();
506
+ *
507
+ * @example
508
+ * app.withRadar({
509
+ * collectorUrl: 'https://collector.example.com',
510
+ * apiKey: process.env.RADAR_API_KEY,
511
+ * projectName: 'my-api',
512
+ * });
513
+ *
514
+ * @example
515
+ * // Capture request/response bodies and selected headers,
516
+ * // with extra client-side masking for app-specific fields.
517
+ * app.withRadar({
518
+ * apiKey: process.env.RADAR_API_KEY,
519
+ * capture: {
520
+ * request: true,
521
+ * response: true,
522
+ * headers: ['content-type', 'x-request-id'],
523
+ * },
524
+ * mask: {
525
+ * fields: ['account_number', 'internal_id'],
526
+ * },
527
+ * });
528
+ */
529
+ withRadar(config = {}) {
530
+ this.midair(radarMiddleware(config));
531
+ return this;
532
+ }
533
+
388
534
  /**
389
535
  * Serves the API documentation at GET /docs and GET /docs/openapi.json from a pre-generated spec file.
390
536
  * Generate the spec with `tejas generate:docs`, then call this to serve it on your app.
@@ -401,16 +547,21 @@ class Tejas {
401
547
  * app.takeoff();
402
548
  */
403
549
  serveDocs(config = {}) {
404
- const specPath = path.resolve(process.cwd(), config.specPath || './openapi.json');
550
+ const specPath = path.resolve(
551
+ process.cwd(),
552
+ config.specPath || './openapi.json',
553
+ );
405
554
  const { scalarConfig } = config;
406
555
  const getSpec = async () => {
407
556
  const content = await readFile(specPath, 'utf8');
408
557
  return JSON.parse(content);
409
558
  };
410
- registerDocRoutes({ getSpec, specUrl: '/docs/openapi.json', scalarConfig }, targetRegistry);
559
+ registerDocRoutes(
560
+ { getSpec, specUrl: '/docs/openapi.json', scalarConfig },
561
+ targetRegistry,
562
+ );
411
563
  return this;
412
564
  }
413
-
414
565
  }
415
566
 
416
567
  const listAllEndpoints = (grouped = false) => {
@@ -421,6 +572,12 @@ export { default as Target } from './server/target.js';
421
572
  export { default as TejFileUploader } from './server/files/uploader.js';
422
573
  export { default as TejError } from './server/error.js';
423
574
  export { listAllEndpoints };
575
+ export {
576
+ contextMiddleware,
577
+ getRequestId,
578
+ getRequestStore,
579
+ requestContext,
580
+ } from './server/context/request-context.js';
424
581
 
425
582
  export default Tejas;
426
583
 
@@ -1,5 +1,5 @@
1
1
  import fs from 'node:fs/promises';
2
- import path from 'path';
2
+ import path from 'node:path';
3
3
 
4
4
  const findTargetFiles = async () => {
5
5
  if (!process.env.DIR_TARGETS) return;
@@ -1,21 +1,35 @@
1
- import * as fs from 'fs';
1
+ /**
2
+ * @fileoverview Configuration loading and normalization utilities.
3
+ *
4
+ * Loads `tejas.config.json` from `process.cwd()` and provides helpers to
5
+ * standardize and flatten config objects so they can be merged with env vars
6
+ * and constructor options.
7
+ */
8
+
9
+ import * as fs from 'node:fs';
2
10
  import { getAllEnv } from 'tej-env';
3
11
 
4
- const loadConfigFile = () => {
12
+ /**
13
+ * Asynchronously read and parse `tejas.config.json` from the current working directory.
14
+ * Returns an empty null-prototype object if the file is missing or unreadable.
15
+ *
16
+ * @returns {Promise<Object>} Parsed config object (may be empty)
17
+ */
18
+ const loadConfigFile = async () => {
5
19
  try {
6
- const data = fs.readFileSync('tejas.config.json', 'utf8');
20
+ const data = await fs.promises.readFile('tejas.config.json', 'utf8');
7
21
  return JSON.parse(data);
8
22
  } catch (err) {
9
- return {};
23
+ return Object.create(null);
10
24
  }
11
25
  };
12
26
 
13
27
  const keysToUpperCase = (obj) => {
14
- if (!obj) return {};
15
- const standardObj = {};
28
+ if (!obj) return Object.create(null);
29
+ const standardObj = Object.create(null);
16
30
 
17
31
  for (const key in obj) {
18
- if (obj.hasOwnProperty(key)) {
32
+ if (Object.hasOwn(obj, key)) {
19
33
  const value = obj[key];
20
34
  const upperKey = key.toUpperCase();
21
35
 
@@ -31,10 +45,10 @@ const keysToUpperCase = (obj) => {
31
45
  };
32
46
 
33
47
  const flattenObject = (obj, prefix = '') => {
34
- let flattened = {};
48
+ let flattened = Object.create(null);
35
49
 
36
50
  for (const key in obj) {
37
- if (obj.hasOwnProperty(key)) {
51
+ if (Object.hasOwn(obj, key)) {
38
52
  const value = obj[key];
39
53
  const newKey = prefix ? `${prefix}_${key}` : key;
40
54
 
@@ -0,0 +1,58 @@
1
+ /**
2
+ * @fileoverview Tests for utils/configuration.js
3
+ */
4
+ import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest';
5
+
6
+ describe('loadConfigFile', () => {
7
+ let loadConfigFile;
8
+
9
+ beforeEach(async () => {
10
+ // Use dynamic import and mock fs to avoid hitting real filesystem
11
+ vi.resetModules();
12
+ });
13
+
14
+ afterEach(() => {
15
+ vi.restoreAllMocks();
16
+ });
17
+
18
+ it('should return a null-prototype object on file not found', async () => {
19
+ vi.doMock('node:fs', () => ({
20
+ default: {},
21
+ promises: {
22
+ readFile: vi.fn().mockRejectedValue(new Error('ENOENT')),
23
+ },
24
+ }));
25
+ const mod = await import('./configuration.js');
26
+ const result = await mod.loadConfigFile();
27
+ expect(Object.getPrototypeOf(result)).toBeNull();
28
+ });
29
+
30
+ it('should parse valid JSON config file', async () => {
31
+ const config = { port: 8080, db: { url: 'mongodb://localhost' } };
32
+ vi.doMock('node:fs', () => ({
33
+ default: {},
34
+ promises: {
35
+ readFile: vi.fn().mockResolvedValue(JSON.stringify(config)),
36
+ },
37
+ }));
38
+ const mod = await import('./configuration.js');
39
+ const result = await mod.loadConfigFile();
40
+ expect(result.port).toBe(8080);
41
+ });
42
+ });
43
+
44
+ describe('standardizeObj', () => {
45
+ it('should uppercase keys and flatten nested objects', async () => {
46
+ vi.resetModules();
47
+ const { standardizeObj } = await import('./configuration.js');
48
+ const result = standardizeObj({ db: { url: 'test', port: 5432 } });
49
+ expect(result['DB_URL']).toBe('test');
50
+ expect(result['DB_PORT']).toBe(5432);
51
+ });
52
+
53
+ it('should handle null/undefined gracefully', async () => {
54
+ const { standardizeObj } = await import('./configuration.js');
55
+ expect(() => standardizeObj(null)).not.toThrow();
56
+ expect(() => standardizeObj(undefined)).not.toThrow();
57
+ });
58
+ });