te.js 2.1.6 → 2.2.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.
- package/README.md +1 -12
- package/auto-docs/analysis/handler-analyzer.test.js +106 -0
- package/auto-docs/analysis/source-resolver.test.js +58 -0
- package/auto-docs/constants.js +13 -2
- package/auto-docs/openapi/generator.js +7 -5
- package/auto-docs/openapi/generator.test.js +132 -0
- package/auto-docs/openapi/spec-builders.js +39 -19
- package/cli/docs-command.js +44 -36
- package/cors/index.test.js +82 -0
- package/docs/README.md +1 -2
- package/docs/api-reference.md +124 -186
- package/docs/configuration.md +0 -13
- package/docs/getting-started.md +19 -21
- package/docs/rate-limiting.md +59 -58
- package/lib/llm/client.js +7 -2
- package/lib/llm/index.js +14 -1
- package/lib/llm/parse.test.js +60 -0
- package/package.json +3 -1
- package/radar/index.js +382 -0
- package/rate-limit/base.js +12 -15
- package/rate-limit/index.js +19 -22
- package/rate-limit/index.test.js +93 -0
- package/rate-limit/storage/memory.js +13 -13
- package/rate-limit/storage/redis-install.js +70 -0
- package/rate-limit/storage/redis.js +94 -52
- package/server/ammo/body-parser.js +156 -152
- package/server/ammo/body-parser.test.js +79 -0
- package/server/ammo/enhancer.js +8 -4
- package/server/ammo.js +138 -12
- package/server/context/request-context.js +51 -0
- package/server/context/request-context.test.js +53 -0
- package/server/endpoint.js +15 -0
- package/server/error.js +56 -3
- package/server/error.test.js +45 -0
- package/server/errors/channels/channels.test.js +148 -0
- package/server/errors/channels/index.js +1 -1
- package/server/errors/llm-cache.js +1 -1
- package/server/errors/llm-cache.test.js +160 -0
- package/server/errors/llm-error-service.js +1 -1
- package/server/errors/llm-rate-limiter.test.js +105 -0
- package/server/files/uploader.js +38 -26
- package/server/handler.js +1 -1
- package/server/targets/registry.js +3 -3
- package/server/targets/registry.test.js +108 -0
- package/te.js +233 -183
- package/utils/auto-register.js +1 -1
- package/utils/configuration.js +23 -9
- package/utils/configuration.test.js +58 -0
- package/utils/errors-llm-config.js +74 -8
- package/utils/request-logger.js +49 -3
- package/utils/startup.js +80 -0
- package/database/index.js +0 -165
- package/database/mongodb.js +0 -146
- package/database/redis.js +0 -201
- package/docs/database.md +0 -390
|
@@ -0,0 +1,160 @@
|
|
|
1
|
+
import { describe, it, expect, beforeEach, vi } from 'vitest';
|
|
2
|
+
import { LLMErrorCache, getCache } from './llm-cache.js';
|
|
3
|
+
|
|
4
|
+
describe('LLMErrorCache', () => {
|
|
5
|
+
describe('constructor', () => {
|
|
6
|
+
it('uses provided ttl', () => {
|
|
7
|
+
const cache = new LLMErrorCache(5000);
|
|
8
|
+
expect(cache.ttl).toBe(5000);
|
|
9
|
+
});
|
|
10
|
+
|
|
11
|
+
it('defaults to 3600000 for invalid ttl', () => {
|
|
12
|
+
expect(new LLMErrorCache(0).ttl).toBe(3_600_000);
|
|
13
|
+
expect(new LLMErrorCache(-1).ttl).toBe(3_600_000);
|
|
14
|
+
});
|
|
15
|
+
});
|
|
16
|
+
|
|
17
|
+
describe('buildKey()', () => {
|
|
18
|
+
it('builds key from first snippet file, line, and error message', () => {
|
|
19
|
+
const cache = new LLMErrorCache(1000);
|
|
20
|
+
const codeContext = {
|
|
21
|
+
snippets: [{ file: '/app/routes/users.js', line: 42 }],
|
|
22
|
+
};
|
|
23
|
+
const error = new Error('User not found');
|
|
24
|
+
const key = cache.buildKey(codeContext, error);
|
|
25
|
+
expect(key).toBe('/app/routes/users.js:42:User not found');
|
|
26
|
+
});
|
|
27
|
+
|
|
28
|
+
it('handles string error', () => {
|
|
29
|
+
const cache = new LLMErrorCache(1000);
|
|
30
|
+
const codeContext = { snippets: [{ file: '/app/handler.js', line: 10 }] };
|
|
31
|
+
const key = cache.buildKey(codeContext, 'Validation failed');
|
|
32
|
+
expect(key).toBe('/app/handler.js:10:Validation failed');
|
|
33
|
+
});
|
|
34
|
+
|
|
35
|
+
it('handles no error (empty string suffix)', () => {
|
|
36
|
+
const cache = new LLMErrorCache(1000);
|
|
37
|
+
const codeContext = { snippets: [{ file: '/app/handler.js', line: 5 }] };
|
|
38
|
+
const key = cache.buildKey(codeContext, undefined);
|
|
39
|
+
expect(key).toBe('/app/handler.js:5:');
|
|
40
|
+
});
|
|
41
|
+
|
|
42
|
+
it('uses "unknown" when codeContext has no snippets', () => {
|
|
43
|
+
const cache = new LLMErrorCache(1000);
|
|
44
|
+
const key = cache.buildKey({ snippets: [] }, new Error('oops'));
|
|
45
|
+
expect(key).toBe('unknown:oops');
|
|
46
|
+
});
|
|
47
|
+
|
|
48
|
+
it('uses "unknown" when codeContext is missing', () => {
|
|
49
|
+
const cache = new LLMErrorCache(1000);
|
|
50
|
+
const key = cache.buildKey(null, new Error('oops'));
|
|
51
|
+
expect(key).toBe('unknown:oops');
|
|
52
|
+
});
|
|
53
|
+
});
|
|
54
|
+
|
|
55
|
+
describe('set() and get()', () => {
|
|
56
|
+
it('stores and retrieves a result', () => {
|
|
57
|
+
const cache = new LLMErrorCache(10_000);
|
|
58
|
+
cache.set('key1', { statusCode: 404, message: 'Not found' });
|
|
59
|
+
const result = cache.get('key1');
|
|
60
|
+
expect(result).toEqual({ statusCode: 404, message: 'Not found' });
|
|
61
|
+
});
|
|
62
|
+
|
|
63
|
+
it('returns null for missing keys', () => {
|
|
64
|
+
const cache = new LLMErrorCache(10_000);
|
|
65
|
+
expect(cache.get('nonexistent')).toBeNull();
|
|
66
|
+
});
|
|
67
|
+
|
|
68
|
+
it('does not return cachedAt in the result', () => {
|
|
69
|
+
const cache = new LLMErrorCache(10_000);
|
|
70
|
+
cache.set('key1', { statusCode: 500, message: 'Error' });
|
|
71
|
+
const result = cache.get('key1');
|
|
72
|
+
expect(result).not.toHaveProperty('cachedAt');
|
|
73
|
+
});
|
|
74
|
+
|
|
75
|
+
it('includes devInsight when stored', () => {
|
|
76
|
+
const cache = new LLMErrorCache(10_000);
|
|
77
|
+
cache.set('key1', {
|
|
78
|
+
statusCode: 404,
|
|
79
|
+
message: 'Not found',
|
|
80
|
+
devInsight: 'Check the ID param.',
|
|
81
|
+
});
|
|
82
|
+
const result = cache.get('key1');
|
|
83
|
+
expect(result.devInsight).toBe('Check the ID param.');
|
|
84
|
+
});
|
|
85
|
+
});
|
|
86
|
+
|
|
87
|
+
describe('TTL expiry', () => {
|
|
88
|
+
it('returns null for expired entries', () => {
|
|
89
|
+
vi.useFakeTimers();
|
|
90
|
+
|
|
91
|
+
const cache = new LLMErrorCache(1000);
|
|
92
|
+
cache.set('key1', { statusCode: 404, message: 'Not found' });
|
|
93
|
+
expect(cache.get('key1')).not.toBeNull();
|
|
94
|
+
|
|
95
|
+
vi.advanceTimersByTime(1001);
|
|
96
|
+
|
|
97
|
+
expect(cache.get('key1')).toBeNull();
|
|
98
|
+
|
|
99
|
+
vi.useRealTimers();
|
|
100
|
+
});
|
|
101
|
+
|
|
102
|
+
it('removes expired entry from the store on access', () => {
|
|
103
|
+
vi.useFakeTimers();
|
|
104
|
+
|
|
105
|
+
const cache = new LLMErrorCache(500);
|
|
106
|
+
cache.set('key1', { statusCode: 500, message: 'Error' });
|
|
107
|
+
expect(cache.size).toBe(1);
|
|
108
|
+
|
|
109
|
+
vi.advanceTimersByTime(600);
|
|
110
|
+
cache.get('key1');
|
|
111
|
+
|
|
112
|
+
expect(cache.size).toBe(0);
|
|
113
|
+
|
|
114
|
+
vi.useRealTimers();
|
|
115
|
+
});
|
|
116
|
+
|
|
117
|
+
it('non-expired entries remain accessible', () => {
|
|
118
|
+
vi.useFakeTimers();
|
|
119
|
+
|
|
120
|
+
const cache = new LLMErrorCache(5000);
|
|
121
|
+
cache.set('key1', { statusCode: 200, message: 'OK' });
|
|
122
|
+
|
|
123
|
+
vi.advanceTimersByTime(4999);
|
|
124
|
+
|
|
125
|
+
expect(cache.get('key1')).not.toBeNull();
|
|
126
|
+
|
|
127
|
+
vi.useRealTimers();
|
|
128
|
+
});
|
|
129
|
+
});
|
|
130
|
+
|
|
131
|
+
describe('size', () => {
|
|
132
|
+
it('tracks the number of entries', () => {
|
|
133
|
+
const cache = new LLMErrorCache(10_000);
|
|
134
|
+
expect(cache.size).toBe(0);
|
|
135
|
+
cache.set('a', { statusCode: 200, message: 'OK' });
|
|
136
|
+
cache.set('b', { statusCode: 404, message: 'Not found' });
|
|
137
|
+
expect(cache.size).toBe(2);
|
|
138
|
+
});
|
|
139
|
+
});
|
|
140
|
+
});
|
|
141
|
+
|
|
142
|
+
describe('getCache (singleton)', () => {
|
|
143
|
+
it('returns a LLMErrorCache instance', () => {
|
|
144
|
+
const cache = getCache(3600000);
|
|
145
|
+
expect(cache).toBeInstanceOf(LLMErrorCache);
|
|
146
|
+
});
|
|
147
|
+
|
|
148
|
+
it('returns same instance for same ttl', () => {
|
|
149
|
+
const a = getCache(3600000);
|
|
150
|
+
const b = getCache(3600000);
|
|
151
|
+
expect(a).toBe(b);
|
|
152
|
+
});
|
|
153
|
+
|
|
154
|
+
it('creates a new instance when ttl changes', () => {
|
|
155
|
+
const a = getCache(1000);
|
|
156
|
+
const b = getCache(2000);
|
|
157
|
+
expect(a).not.toBe(b);
|
|
158
|
+
expect(b.ttl).toBe(2000);
|
|
159
|
+
});
|
|
160
|
+
});
|
|
@@ -51,7 +51,7 @@ function buildPrompt(context) {
|
|
|
51
51
|
|
|
52
52
|
let errorPart = '';
|
|
53
53
|
if (error !== undefined && error !== null) {
|
|
54
|
-
if (error
|
|
54
|
+
if (error != null && typeof error.message === 'string') {
|
|
55
55
|
errorPart = `\nOptional error message (may be empty): ${error.message}`;
|
|
56
56
|
} else {
|
|
57
57
|
errorPart = `\nOptional error/message: ${String(error)}`;
|
|
@@ -0,0 +1,105 @@
|
|
|
1
|
+
import { describe, it, expect, beforeEach, vi } from 'vitest';
|
|
2
|
+
import { LLMRateLimiter, getRateLimiter } from './llm-rate-limiter.js';
|
|
3
|
+
|
|
4
|
+
describe('LLMRateLimiter', () => {
|
|
5
|
+
describe('constructor', () => {
|
|
6
|
+
it('uses provided maxPerMinute', () => {
|
|
7
|
+
const limiter = new LLMRateLimiter(5);
|
|
8
|
+
expect(limiter.maxPerMinute).toBe(5);
|
|
9
|
+
});
|
|
10
|
+
|
|
11
|
+
it('defaults to 10 when maxPerMinute is invalid', () => {
|
|
12
|
+
expect(new LLMRateLimiter(0).maxPerMinute).toBe(10);
|
|
13
|
+
expect(new LLMRateLimiter(-1).maxPerMinute).toBe(10);
|
|
14
|
+
});
|
|
15
|
+
|
|
16
|
+
it('floors non-integer values', () => {
|
|
17
|
+
expect(new LLMRateLimiter(4.9).maxPerMinute).toBe(4);
|
|
18
|
+
});
|
|
19
|
+
});
|
|
20
|
+
|
|
21
|
+
describe('canCall() and record()', () => {
|
|
22
|
+
it('allows calls when under the limit', () => {
|
|
23
|
+
const limiter = new LLMRateLimiter(3);
|
|
24
|
+
expect(limiter.canCall()).toBe(true);
|
|
25
|
+
});
|
|
26
|
+
|
|
27
|
+
it('blocks calls when at the limit', () => {
|
|
28
|
+
const limiter = new LLMRateLimiter(2);
|
|
29
|
+
limiter.record();
|
|
30
|
+
limiter.record();
|
|
31
|
+
expect(limiter.canCall()).toBe(false);
|
|
32
|
+
});
|
|
33
|
+
|
|
34
|
+
it('allows calls again after recording up to max', () => {
|
|
35
|
+
const limiter = new LLMRateLimiter(1);
|
|
36
|
+
expect(limiter.canCall()).toBe(true);
|
|
37
|
+
limiter.record();
|
|
38
|
+
expect(limiter.canCall()).toBe(false);
|
|
39
|
+
});
|
|
40
|
+
|
|
41
|
+
it('remaining() returns correct count', () => {
|
|
42
|
+
const limiter = new LLMRateLimiter(3);
|
|
43
|
+
expect(limiter.remaining()).toBe(3);
|
|
44
|
+
limiter.record();
|
|
45
|
+
expect(limiter.remaining()).toBe(2);
|
|
46
|
+
limiter.record();
|
|
47
|
+
expect(limiter.remaining()).toBe(1);
|
|
48
|
+
limiter.record();
|
|
49
|
+
expect(limiter.remaining()).toBe(0);
|
|
50
|
+
});
|
|
51
|
+
});
|
|
52
|
+
|
|
53
|
+
describe('sliding window pruning', () => {
|
|
54
|
+
it('expires old timestamps after 60 seconds', () => {
|
|
55
|
+
vi.useFakeTimers();
|
|
56
|
+
|
|
57
|
+
const limiter = new LLMRateLimiter(2);
|
|
58
|
+
limiter.record();
|
|
59
|
+
limiter.record();
|
|
60
|
+
expect(limiter.canCall()).toBe(false);
|
|
61
|
+
|
|
62
|
+
vi.advanceTimersByTime(61_000);
|
|
63
|
+
|
|
64
|
+
expect(limiter.canCall()).toBe(true);
|
|
65
|
+
|
|
66
|
+
vi.useRealTimers();
|
|
67
|
+
});
|
|
68
|
+
|
|
69
|
+
it('only expires timestamps older than 60 seconds', () => {
|
|
70
|
+
vi.useFakeTimers();
|
|
71
|
+
|
|
72
|
+
const limiter = new LLMRateLimiter(2);
|
|
73
|
+
limiter.record();
|
|
74
|
+
|
|
75
|
+
vi.advanceTimersByTime(50_000);
|
|
76
|
+
limiter.record();
|
|
77
|
+
|
|
78
|
+
vi.advanceTimersByTime(15_000);
|
|
79
|
+
expect(limiter.canCall()).toBe(true);
|
|
80
|
+
expect(limiter.remaining()).toBe(1);
|
|
81
|
+
|
|
82
|
+
vi.useRealTimers();
|
|
83
|
+
});
|
|
84
|
+
});
|
|
85
|
+
});
|
|
86
|
+
|
|
87
|
+
describe('getRateLimiter (singleton)', () => {
|
|
88
|
+
it('returns a LLMRateLimiter instance', () => {
|
|
89
|
+
const limiter = getRateLimiter(10);
|
|
90
|
+
expect(limiter).toBeInstanceOf(LLMRateLimiter);
|
|
91
|
+
});
|
|
92
|
+
|
|
93
|
+
it('returns same instance for same maxPerMinute', () => {
|
|
94
|
+
const a = getRateLimiter(10);
|
|
95
|
+
const b = getRateLimiter(10);
|
|
96
|
+
expect(a).toBe(b);
|
|
97
|
+
});
|
|
98
|
+
|
|
99
|
+
it('creates a new instance when maxPerMinute changes', () => {
|
|
100
|
+
const a = getRateLimiter(5);
|
|
101
|
+
const b = getRateLimiter(15);
|
|
102
|
+
expect(a).not.toBe(b);
|
|
103
|
+
expect(b.maxPerMinute).toBe(15);
|
|
104
|
+
});
|
|
105
|
+
});
|
package/server/files/uploader.js
CHANGED
|
@@ -1,5 +1,5 @@
|
|
|
1
1
|
import { filesize } from 'filesize';
|
|
2
|
-
import
|
|
2
|
+
import fsp from 'node:fs/promises';
|
|
3
3
|
import TejError from './../error.js';
|
|
4
4
|
import { extAndType, extract, paths } from './helper.js';
|
|
5
5
|
|
|
@@ -19,11 +19,11 @@ class TejFileUploader {
|
|
|
19
19
|
file() {
|
|
20
20
|
const keys = [...arguments];
|
|
21
21
|
return async (ammo, next) => {
|
|
22
|
-
if (!ammo.headers['content-type']
|
|
22
|
+
if (!ammo.headers['content-type']?.startsWith('multipart/form-data'))
|
|
23
23
|
return next();
|
|
24
24
|
|
|
25
25
|
const payload = ammo.payload;
|
|
26
|
-
const updatedPayload =
|
|
26
|
+
const updatedPayload = Object.create(null);
|
|
27
27
|
|
|
28
28
|
for (const part in payload) {
|
|
29
29
|
const obj = payload[part];
|
|
@@ -43,26 +43,32 @@ class TejFileUploader {
|
|
|
43
43
|
if (!filename) continue;
|
|
44
44
|
|
|
45
45
|
const { dir, absolute, relative } = paths(this.destination, filename);
|
|
46
|
-
const size = filesize(obj.value.length,
|
|
47
|
-
|
|
48
|
-
|
|
49
|
-
|
|
46
|
+
const size = filesize(obj.value.length, {
|
|
47
|
+
output: 'object',
|
|
48
|
+
round: 0,
|
|
49
|
+
});
|
|
50
|
+
const maxSize = filesize(this.maxFileSize, {
|
|
51
|
+
output: 'object',
|
|
52
|
+
round: 0,
|
|
53
|
+
});
|
|
50
54
|
if (this.maxFileSize && obj.value.length > this.maxFileSize)
|
|
51
|
-
throw new TejError(
|
|
52
|
-
|
|
55
|
+
throw new TejError(
|
|
56
|
+
413,
|
|
57
|
+
`File size exceeds ${maxSize.value} ${maxSize.symbol}`,
|
|
58
|
+
);
|
|
53
59
|
|
|
54
|
-
|
|
55
|
-
|
|
60
|
+
await fsp.mkdir(dir, { recursive: true });
|
|
61
|
+
await fsp.writeFile(absolute, obj.value, 'binary');
|
|
56
62
|
|
|
57
63
|
updatedPayload[key] = {
|
|
58
64
|
filename,
|
|
59
65
|
extension: ext,
|
|
60
66
|
path: {
|
|
61
67
|
absolute: absolute,
|
|
62
|
-
relative: relative
|
|
68
|
+
relative: relative,
|
|
63
69
|
},
|
|
64
70
|
mimetype: type,
|
|
65
|
-
size
|
|
71
|
+
size,
|
|
66
72
|
};
|
|
67
73
|
}
|
|
68
74
|
}
|
|
@@ -75,11 +81,11 @@ class TejFileUploader {
|
|
|
75
81
|
files() {
|
|
76
82
|
const keys = [...arguments];
|
|
77
83
|
return async (ammo, next) => {
|
|
78
|
-
if (!ammo.headers['content-type']
|
|
84
|
+
if (!ammo.headers['content-type']?.startsWith('multipart/form-data'))
|
|
79
85
|
return next();
|
|
80
86
|
|
|
81
87
|
const payload = ammo.payload;
|
|
82
|
-
const updatedPayload =
|
|
88
|
+
const updatedPayload = Object.create(null);
|
|
83
89
|
const files = [];
|
|
84
90
|
|
|
85
91
|
for (const part in payload) {
|
|
@@ -99,27 +105,33 @@ class TejFileUploader {
|
|
|
99
105
|
if (!filename) continue;
|
|
100
106
|
|
|
101
107
|
const { dir, absolute, relative } = paths(this.destination, filename);
|
|
102
|
-
const size = filesize(obj.value.length,
|
|
103
|
-
|
|
104
|
-
|
|
105
|
-
|
|
108
|
+
const size = filesize(obj.value.length, {
|
|
109
|
+
output: 'object',
|
|
110
|
+
round: 0,
|
|
111
|
+
});
|
|
112
|
+
const maxSize = filesize(this.maxFileSize, {
|
|
113
|
+
output: 'object',
|
|
114
|
+
round: 0,
|
|
115
|
+
});
|
|
106
116
|
if (this.maxFileSize && obj.value.length > this.maxFileSize) {
|
|
107
|
-
throw new TejError(
|
|
108
|
-
|
|
117
|
+
throw new TejError(
|
|
118
|
+
413,
|
|
119
|
+
`File size exceeds ${maxSize.value} ${maxSize.symbol}`,
|
|
120
|
+
);
|
|
109
121
|
}
|
|
110
122
|
|
|
111
|
-
|
|
112
|
-
|
|
123
|
+
await fsp.mkdir(dir, { recursive: true });
|
|
124
|
+
await fsp.writeFile(absolute, obj.value, 'binary');
|
|
113
125
|
|
|
114
126
|
files.push({
|
|
115
127
|
key,
|
|
116
128
|
filename,
|
|
117
129
|
path: {
|
|
118
130
|
absolute: absolute,
|
|
119
|
-
relative: relative
|
|
131
|
+
relative: relative,
|
|
120
132
|
},
|
|
121
133
|
mimetype: type,
|
|
122
|
-
size
|
|
134
|
+
size,
|
|
123
135
|
});
|
|
124
136
|
}
|
|
125
137
|
}
|
|
@@ -128,7 +140,7 @@ class TejFileUploader {
|
|
|
128
140
|
if (!acc[file.key]) acc[file.key] = [];
|
|
129
141
|
acc[file.key].push(file);
|
|
130
142
|
return acc;
|
|
131
|
-
},
|
|
143
|
+
}, Object.create(null));
|
|
132
144
|
|
|
133
145
|
for (const key in groupedFilesByKey) {
|
|
134
146
|
updatedPayload[key] = groupedFilesByKey[key];
|
package/server/handler.js
CHANGED
|
@@ -58,7 +58,7 @@ class TargetRegistry {
|
|
|
58
58
|
});
|
|
59
59
|
|
|
60
60
|
if (exactMatch) {
|
|
61
|
-
return { target: exactMatch, params:
|
|
61
|
+
return { target: exactMatch, params: Object.create(null) };
|
|
62
62
|
}
|
|
63
63
|
|
|
64
64
|
// Then, try parameterized route matching
|
|
@@ -104,7 +104,7 @@ class TargetRegistry {
|
|
|
104
104
|
return null;
|
|
105
105
|
}
|
|
106
106
|
|
|
107
|
-
const params =
|
|
107
|
+
const params = Object.create(null);
|
|
108
108
|
|
|
109
109
|
// Match each segment
|
|
110
110
|
for (let i = 0; i < patternSegments.length; i++) {
|
|
@@ -150,7 +150,7 @@ class TargetRegistry {
|
|
|
150
150
|
if (!acc[group]) acc[group] = [];
|
|
151
151
|
acc[group].push(target.getPath());
|
|
152
152
|
return acc;
|
|
153
|
-
},
|
|
153
|
+
}, Object.create(null));
|
|
154
154
|
}
|
|
155
155
|
return this.targets.map((target) => target.getPath());
|
|
156
156
|
}
|
|
@@ -0,0 +1,108 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* @fileoverview Tests for TargetRegistry routing logic.
|
|
3
|
+
*/
|
|
4
|
+
import { describe, it, expect, beforeEach } from 'vitest';
|
|
5
|
+
|
|
6
|
+
// Use a fresh instance per test by clearing the singleton
|
|
7
|
+
let TargetRegistry;
|
|
8
|
+
let registry;
|
|
9
|
+
|
|
10
|
+
beforeEach(async () => {
|
|
11
|
+
// Reset module cache to get fresh singleton
|
|
12
|
+
TargetRegistry = (await import('./registry.js')).default.constructor;
|
|
13
|
+
// Re-import to use existing singleton, but clear its state
|
|
14
|
+
const mod = await import('./registry.js');
|
|
15
|
+
registry = mod.default;
|
|
16
|
+
registry.targets = [];
|
|
17
|
+
registry.globalMiddlewares = [];
|
|
18
|
+
});
|
|
19
|
+
|
|
20
|
+
describe('TargetRegistry.aim', () => {
|
|
21
|
+
it('should return null for unmatched routes', () => {
|
|
22
|
+
expect(registry.aim('/api/users')).toBeNull();
|
|
23
|
+
});
|
|
24
|
+
|
|
25
|
+
it('should match exact path', () => {
|
|
26
|
+
const mockTarget = {
|
|
27
|
+
getPath: () => '/api/users',
|
|
28
|
+
getMethods: () => null,
|
|
29
|
+
};
|
|
30
|
+
registry.targets.push(mockTarget);
|
|
31
|
+
const result = registry.aim('/api/users');
|
|
32
|
+
expect(result).not.toBeNull();
|
|
33
|
+
expect(result.target).toBe(mockTarget);
|
|
34
|
+
expect(result.params).toBeDefined();
|
|
35
|
+
});
|
|
36
|
+
|
|
37
|
+
it('should match parameterized route and extract params', () => {
|
|
38
|
+
const mockTarget = {
|
|
39
|
+
getPath: () => '/api/users/:id',
|
|
40
|
+
getMethods: () => null,
|
|
41
|
+
};
|
|
42
|
+
registry.targets.push(mockTarget);
|
|
43
|
+
const result = registry.aim('/api/users/42');
|
|
44
|
+
expect(result).not.toBeNull();
|
|
45
|
+
expect(result.params.id).toBe('42');
|
|
46
|
+
});
|
|
47
|
+
|
|
48
|
+
it('should use Object.create(null) for params (no prototype pollution)', () => {
|
|
49
|
+
const mockTarget = {
|
|
50
|
+
getPath: () => '/api/:resource',
|
|
51
|
+
getMethods: () => null,
|
|
52
|
+
};
|
|
53
|
+
registry.targets.push(mockTarget);
|
|
54
|
+
const result = registry.aim('/api/users');
|
|
55
|
+
// Param key is the route parameter name ('resource'), not the URL value
|
|
56
|
+
expect(result.params['resource']).toBe('users');
|
|
57
|
+
// The params object must use null prototype (safe from prototype pollution)
|
|
58
|
+
expect(Object.getPrototypeOf(result.params)).toBeNull();
|
|
59
|
+
});
|
|
60
|
+
|
|
61
|
+
it('should not match routes with different segment counts', () => {
|
|
62
|
+
const mockTarget = {
|
|
63
|
+
getPath: () => '/api/users/:id',
|
|
64
|
+
getMethods: () => null,
|
|
65
|
+
};
|
|
66
|
+
registry.targets.push(mockTarget);
|
|
67
|
+
expect(registry.aim('/api/users')).toBeNull();
|
|
68
|
+
expect(registry.aim('/api/users/42/profile')).toBeNull();
|
|
69
|
+
});
|
|
70
|
+
});
|
|
71
|
+
|
|
72
|
+
describe('TargetRegistry.getAllEndpoints', () => {
|
|
73
|
+
it('should return flat path list by default', () => {
|
|
74
|
+
registry.targets = [
|
|
75
|
+
{
|
|
76
|
+
getPath: () => '/api/users',
|
|
77
|
+
getMetadata: () => null,
|
|
78
|
+
getHandler: () => null,
|
|
79
|
+
},
|
|
80
|
+
{
|
|
81
|
+
getPath: () => '/api/posts',
|
|
82
|
+
getMetadata: () => null,
|
|
83
|
+
getHandler: () => null,
|
|
84
|
+
},
|
|
85
|
+
];
|
|
86
|
+
expect(registry.getAllEndpoints()).toEqual(['/api/users', '/api/posts']);
|
|
87
|
+
});
|
|
88
|
+
|
|
89
|
+
it('should return grouped object when grouped=true', () => {
|
|
90
|
+
registry.targets = [
|
|
91
|
+
{
|
|
92
|
+
getPath: () => '/api/users',
|
|
93
|
+
getMetadata: () => null,
|
|
94
|
+
getHandler: () => null,
|
|
95
|
+
},
|
|
96
|
+
{
|
|
97
|
+
getPath: () => '/api/posts',
|
|
98
|
+
getMetadata: () => null,
|
|
99
|
+
getHandler: () => null,
|
|
100
|
+
},
|
|
101
|
+
];
|
|
102
|
+
const grouped = registry.getAllEndpoints(true);
|
|
103
|
+
expect(grouped['api']).toContain('/api/users');
|
|
104
|
+
expect(grouped['api']).toContain('/api/posts');
|
|
105
|
+
// Result must be null-prototype dict
|
|
106
|
+
expect(Object.getPrototypeOf(grouped)).toBeNull();
|
|
107
|
+
});
|
|
108
|
+
});
|