@tyno/tyno 2.1.3
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-zh.md +572 -0
- package/README.md +555 -0
- package/example/app.ts +173 -0
- package/example/public/index.html +1 -0
- package/example/public/test.json +1 -0
- package/package.json +63 -0
- package/scripts/build.mjs +97 -0
- package/scripts/rename-cjs.mjs +23 -0
- package/src/application.ts +304 -0
- package/src/cache/drivers/file.ts +79 -0
- package/src/cache/drivers/memory.ts +72 -0
- package/src/cache/drivers/redis.ts +72 -0
- package/src/cache/index.ts +5 -0
- package/src/cache/manager.ts +106 -0
- package/src/cache/types.ts +24 -0
- package/src/cache-facade.ts +64 -0
- package/src/compose.ts +139 -0
- package/src/context.ts +5 -0
- package/src/errors/app-error.ts +37 -0
- package/src/errors/http-error.ts +34 -0
- package/src/errors/index.ts +4 -0
- package/src/errors/runtime-error.ts +19 -0
- package/src/facade/index.ts +3 -0
- package/src/index.ts +29 -0
- package/src/middlewares/compress.ts +101 -0
- package/src/middlewares/cors.ts +57 -0
- package/src/middlewares/error-page.ts +89 -0
- package/src/middlewares/index.ts +9 -0
- package/src/middlewares/request-id.ts +47 -0
- package/src/middlewares/static.ts +138 -0
- package/src/mime.ts +38 -0
- package/src/request/body-parser.ts +61 -0
- package/src/request/index.ts +273 -0
- package/src/request/multipart-parser.ts +360 -0
- package/src/request-global.ts +31 -0
- package/src/response/sse.ts +54 -0
- package/src/response.ts +177 -0
- package/src/router/index.ts +290 -0
- package/src/router/node.ts +15 -0
- package/src/router/parse-path.ts +18 -0
- package/src/types.ts +109 -0
- package/test/functional.test.ts +614 -0
- package/tsconfig.build.json +13 -0
- package/tsconfig.cjs.json +9 -0
- package/tsconfig.json +21 -0
|
@@ -0,0 +1,614 @@
|
|
|
1
|
+
import { Tyno, Router, Response, HttpError, RuntimeError, NotFound, cors, requestId, compress, asErrorMiddleware, response, CacheManager, MemoryDriver, cache } from '../src/index.ts';
|
|
2
|
+
import type { TynoRequest, NextFunction } from '../src/index.ts';
|
|
3
|
+
|
|
4
|
+
let passed = 0;
|
|
5
|
+
let failed = 0;
|
|
6
|
+
function test(name: string, condition: boolean) {
|
|
7
|
+
if (condition) { passed++; console.log(` ✅ ${name}`); }
|
|
8
|
+
else { failed++; console.log(` ❌ ${name}`); }
|
|
9
|
+
}
|
|
10
|
+
|
|
11
|
+
const app = new Tyno({ debug: true, body: { limit: '2mb', uploadLimit: '20mb', uploadBufferLimit: '512kb' } });
|
|
12
|
+
|
|
13
|
+
app.use(requestId());
|
|
14
|
+
app.use(cors({ origin: '*' }));
|
|
15
|
+
app.use(compress({ threshold: 5 }));
|
|
16
|
+
|
|
17
|
+
app.use(async (req: TynoRequest, next: NextFunction) => {
|
|
18
|
+
const res = await next();
|
|
19
|
+
return res;
|
|
20
|
+
});
|
|
21
|
+
|
|
22
|
+
const r = new Router({ prefix: '/api' });
|
|
23
|
+
r.get('/users/:id(\\d+)', (req: TynoRequest) => ({ id: req.params.id }));
|
|
24
|
+
r.post('/echo', async (req: TynoRequest) => ({ body: await req.body() }));
|
|
25
|
+
r.get('/*', (req: TynoRequest) => ({ wild: req.params['*'] }));
|
|
26
|
+
app.use(r.routes());
|
|
27
|
+
|
|
28
|
+
app.use(async (req: TynoRequest) => {
|
|
29
|
+
if (req.path === '/hello') return 'Hello!';
|
|
30
|
+
if (req.path === '/json') return Response.json({ ok: true });
|
|
31
|
+
if (req.path === '/cookie-test') {
|
|
32
|
+
return Response.json({ cookie: req.getCookie('test') || null })
|
|
33
|
+
.setCookie('test', 'value123', { httpOnly: true });
|
|
34
|
+
}
|
|
35
|
+
// Body parsing test routes
|
|
36
|
+
if (req.path === '/body-json' && req.method === 'POST') {
|
|
37
|
+
const body = await req.body();
|
|
38
|
+
return Response.json({ parsed: body, type: typeof body });
|
|
39
|
+
}
|
|
40
|
+
if (req.path === '/body-urlencoded' && req.method === 'POST') {
|
|
41
|
+
const body = await req.body();
|
|
42
|
+
return Response.json({ parsed: body });
|
|
43
|
+
}
|
|
44
|
+
if (req.path === '/body-text' && req.method === 'POST') {
|
|
45
|
+
const body = await req.body();
|
|
46
|
+
return Response.json({ parsed: body, isString: typeof body === 'string' });
|
|
47
|
+
}
|
|
48
|
+
if (req.path === '/post-field' && req.method === 'POST') {
|
|
49
|
+
const name = await req.post('name');
|
|
50
|
+
const age = await req.post('age');
|
|
51
|
+
return Response.json({ name, age });
|
|
52
|
+
}
|
|
53
|
+
if (req.path === '/param-test') {
|
|
54
|
+
const id = await req.param('id');
|
|
55
|
+
const name = await req.param('name');
|
|
56
|
+
return Response.json({ id, name });
|
|
57
|
+
}
|
|
58
|
+
if (req.path === '/input-test') {
|
|
59
|
+
const age = await req.input('age', 0, (v: unknown) => parseInt(String(v), 10));
|
|
60
|
+
const name = await req.input('missing', 'default');
|
|
61
|
+
return Response.json({ age, name, ageType: typeof age });
|
|
62
|
+
}
|
|
63
|
+
if (req.path === '/query-test') {
|
|
64
|
+
const q = req.getQuery('q', 'none');
|
|
65
|
+
return Response.json({ q });
|
|
66
|
+
}
|
|
67
|
+
if (req.path === '/query-all') {
|
|
68
|
+
const all = req.query();
|
|
69
|
+
const single = req.query('a');
|
|
70
|
+
const viaGet = req.get('b');
|
|
71
|
+
const viaGetDefault = req.get('missing', 'fallback');
|
|
72
|
+
return Response.json({ all, single, viaGet, viaGetDefault });
|
|
73
|
+
}
|
|
74
|
+
// Upload test via inject (simulated multipart)
|
|
75
|
+
if (req.path === '/upload-test' && req.method === 'POST') {
|
|
76
|
+
const result = await req.files();
|
|
77
|
+
const single = await req.file('avatar');
|
|
78
|
+
return Response.json({
|
|
79
|
+
fieldCount: Object.keys(result.fields).length,
|
|
80
|
+
fileCount: result.files.length,
|
|
81
|
+
hasAvatar: single !== null,
|
|
82
|
+
avatarName: single?.filename ?? null
|
|
83
|
+
});
|
|
84
|
+
}
|
|
85
|
+
// Full upload detail test: checks buffer, size, disk path, content
|
|
86
|
+
if (req.path === '/upload-detail' && req.method === 'POST') {
|
|
87
|
+
const result = await req.files();
|
|
88
|
+
const file = result.files[0] ?? null;
|
|
89
|
+
const hasBuffer = file?.buffer != null && Buffer.isBuffer(file.buffer);
|
|
90
|
+
const hasPath = file?.filepath != null && file.filepath.length > 0;
|
|
91
|
+
const sizeOk = file?.size != null && file.size > 0;
|
|
92
|
+
// Try to read the file from disk to verify content
|
|
93
|
+
let diskContent: string | null = null;
|
|
94
|
+
if (hasPath) {
|
|
95
|
+
try {
|
|
96
|
+
const fs = await import('node:fs');
|
|
97
|
+
diskContent = fs.readFileSync(file!.filepath, 'utf-8');
|
|
98
|
+
} catch { diskContent = null; }
|
|
99
|
+
}
|
|
100
|
+
return Response.json({
|
|
101
|
+
fieldname: file?.fieldname,
|
|
102
|
+
filename: file?.filename,
|
|
103
|
+
mimetype: file?.mimetype,
|
|
104
|
+
size: file?.size,
|
|
105
|
+
hasBuffer,
|
|
106
|
+
hasPath,
|
|
107
|
+
diskContent,
|
|
108
|
+
bufferContent: hasBuffer ? file!.buffer!.toString('utf-8') : null
|
|
109
|
+
});
|
|
110
|
+
}
|
|
111
|
+
// Small file vs large file buffer threshold test
|
|
112
|
+
if (req.path === '/upload-threshold' && req.method === 'POST') {
|
|
113
|
+
const result = await req.files();
|
|
114
|
+
const file = result.files[0] ?? null;
|
|
115
|
+
const inMemory = file?.buffer != null;
|
|
116
|
+
const onDisk = file?.filepath != null && file.filepath.length > 0;
|
|
117
|
+
// Verify file on disk actually exists and has content
|
|
118
|
+
let diskExists = false;
|
|
119
|
+
let diskSize = 0;
|
|
120
|
+
if (onDisk) {
|
|
121
|
+
try {
|
|
122
|
+
const fs = await import('node:fs');
|
|
123
|
+
const stat = fs.statSync(file!.filepath);
|
|
124
|
+
diskExists = stat.isFile();
|
|
125
|
+
diskSize = stat.size;
|
|
126
|
+
} catch { diskExists = false; }
|
|
127
|
+
}
|
|
128
|
+
return Response.json({
|
|
129
|
+
filename: file?.filename,
|
|
130
|
+
size: file?.size,
|
|
131
|
+
inMemory,
|
|
132
|
+
onDisk,
|
|
133
|
+
diskExists,
|
|
134
|
+
diskSize
|
|
135
|
+
});
|
|
136
|
+
}
|
|
137
|
+
|
|
138
|
+
if (req.path === '/error-404') throw new NotFound('gone');
|
|
139
|
+
if (req.path === '/error-500') throw new RuntimeError('db fail', { code: 'DB', cause: new Error('ECONNREFUSED') });
|
|
140
|
+
if (req.path === '/stream') {
|
|
141
|
+
async function* gen() { yield 'a'; yield 'b'; }
|
|
142
|
+
return gen();
|
|
143
|
+
}
|
|
144
|
+
return new Response(404, {}, 'Not Found');
|
|
145
|
+
});
|
|
146
|
+
|
|
147
|
+
async function main() {
|
|
148
|
+
console.log('=== Tyono Comprehensive Test Suite ===\n');
|
|
149
|
+
|
|
150
|
+
// Test 1: Basic endpoints
|
|
151
|
+
console.log('[1] Basic Endpoints');
|
|
152
|
+
const r1a = await app.inject({ method: 'GET', url: '/hello' });
|
|
153
|
+
test('GET /hello → 200', r1a.status === 200 && r1a.body === 'Hello!');
|
|
154
|
+
const r1b = await app.inject({ method: 'GET', url: '/json' });
|
|
155
|
+
test('GET /json → 200 JSON', r1b.status === 200 && r1b.json().ok === true);
|
|
156
|
+
const r1c = await app.inject({ method: 'GET', url: '/nope' });
|
|
157
|
+
test('GET /nope → 404', r1c.status === 404);
|
|
158
|
+
|
|
159
|
+
// Test 2: Compression
|
|
160
|
+
console.log('\n[2] Compression');
|
|
161
|
+
test('compress() middleware exists', typeof compress === 'function');
|
|
162
|
+
const r2a = await app.inject({ method: 'POST', url: '/api/echo', headers: { 'accept-encoding': 'gzip', 'content-type': 'application/json' }, body: { data: 'x'.repeat(200) } });
|
|
163
|
+
const varyHeader = String(r2a.headers['vary'] || '');
|
|
164
|
+
test('Compress: Vary header present', varyHeader.includes('Accept-Encoding'));
|
|
165
|
+
|
|
166
|
+
// Test 3: Router
|
|
167
|
+
console.log('\n[3] Router');
|
|
168
|
+
const r3a = await app.inject({ method: 'GET', url: '/api/users/42' });
|
|
169
|
+
test('GET /api/users/42 → params', r3a.json().id === '42');
|
|
170
|
+
const r3b = await app.inject({ method: 'POST', url: '/api/echo', headers: { 'content-type': 'application/json' }, body: { msg: 'hi' } });
|
|
171
|
+
test('POST /api/echo → body echo', r3b.json().body.msg === 'hi');
|
|
172
|
+
const r3c = await app.inject({ method: 'GET', url: '/api/any/deep/path' });
|
|
173
|
+
test('GET /api/* → wildcard', r3c.json().wild === 'any/deep/path');
|
|
174
|
+
|
|
175
|
+
// Test 4: Error handling
|
|
176
|
+
console.log('\n[4] Error Handling');
|
|
177
|
+
const r4a = await app.inject({ method: 'GET', url: '/error-404' });
|
|
178
|
+
test('HttpError 404 → correct status', r4a.status === 404);
|
|
179
|
+
test('HttpError 404 → exposed message', r4a.json().error === 'gone');
|
|
180
|
+
const r4b = await app.inject({ method: 'GET', url: '/error-500' });
|
|
181
|
+
test('RuntimeError 500 → correct status', r4b.status === 500);
|
|
182
|
+
test('RuntimeError 500 → has code', r4b.json().code === 'DB');
|
|
183
|
+
|
|
184
|
+
// Test 5: Cookies
|
|
185
|
+
console.log('\n[5] Cookies');
|
|
186
|
+
const r5a = await app.inject({ method: 'GET', url: '/cookie-test' });
|
|
187
|
+
const setCookie = String(r5a.headers['set-cookie'] || '');
|
|
188
|
+
test('Cookie: set-cookie header', setCookie.includes('test=value123'));
|
|
189
|
+
const r5b = await app.inject({ method: 'GET', url: '/cookie-test', cookies: { test: 'hello%20world' } });
|
|
190
|
+
test('Cookie: URL decode value', r5b.json().cookie === 'hello world');
|
|
191
|
+
|
|
192
|
+
// Test 6: Request ID
|
|
193
|
+
console.log('\n[6] Request ID');
|
|
194
|
+
const r6a = await app.inject({ method: 'GET', url: '/hello' });
|
|
195
|
+
test('X-Request-Id present', typeof r6a.headers['x-request-id'] === 'string');
|
|
196
|
+
|
|
197
|
+
// Test 7: SSE
|
|
198
|
+
console.log('\n[7] SSE Streaming');
|
|
199
|
+
const r7a = await app.inject({ method: 'GET', url: '/stream' });
|
|
200
|
+
test('SSE: text/event-stream content-type', String(r7a.headers['content-type'] || '').startsWith('text/event-stream'));
|
|
201
|
+
test('SSE: response body contains data', r7a.body.includes('data:'));
|
|
202
|
+
test('SSE: Cache-Control no-cache', r7a.headers['cache-control'] === 'no-cache');
|
|
203
|
+
|
|
204
|
+
// Test 8: CORS
|
|
205
|
+
console.log('\n[8] CORS');
|
|
206
|
+
const r8a = await app.inject({ method: 'OPTIONS', url: '/hello', headers: { origin: 'http://example.com' } });
|
|
207
|
+
test('CORS: OPTIONS returns 204', r8a.status === 204);
|
|
208
|
+
test('CORS: Allow-Origin *', r8a.headers['access-control-allow-origin'] === '*');
|
|
209
|
+
const r8b = await app.inject({ method: 'GET', url: '/hello', headers: { origin: 'http://example.com' } });
|
|
210
|
+
test('CORS: GET includes Allow-Origin', r8b.headers['access-control-allow-origin'] === '*');
|
|
211
|
+
|
|
212
|
+
// Test 9: asErrorMiddleware
|
|
213
|
+
console.log('\n[9] asErrorMiddleware');
|
|
214
|
+
const mw = asErrorMiddleware(async (err, req, next) => { return next(); });
|
|
215
|
+
test('asErrorMiddleware returns function', typeof mw === 'function');
|
|
216
|
+
|
|
217
|
+
// Test 10: Response backward compat
|
|
218
|
+
console.log('\n[10] Response Backward Compat');
|
|
219
|
+
test('Response instance check works', new Response(200) instanceof Response);
|
|
220
|
+
test('Response.json() works', Response.json({ x: 1 }).body === JSON.stringify({ x: 1 }));
|
|
221
|
+
|
|
222
|
+
// ==================== Test 11: Body Parsing ====================
|
|
223
|
+
console.log('\n[11] Body Parsing');
|
|
224
|
+
|
|
225
|
+
// 11a: JSON body
|
|
226
|
+
const r11a = await app.inject({ method: 'POST', url: '/body-json', headers: { 'content-type': 'application/json' }, body: { a: 1, b: 'two' } });
|
|
227
|
+
test('JSON body: correct parse', r11a.json().parsed.a === 1 && r11a.json().parsed.b === 'two');
|
|
228
|
+
test('JSON body: returns object', r11a.json().type === 'object');
|
|
229
|
+
|
|
230
|
+
// 11b: URL-encoded body
|
|
231
|
+
const r11b = await app.inject({ method: 'POST', url: '/body-urlencoded', headers: { 'content-type': 'application/x-www-form-urlencoded' }, body: 'name=Alice&age=30' });
|
|
232
|
+
test('URL-encoded body: correct parse', r11b.json().parsed.name === 'Alice' && r11b.json().parsed.age === '30');
|
|
233
|
+
|
|
234
|
+
// 11c: Plain text body
|
|
235
|
+
const r11c = await app.inject({ method: 'POST', url: '/body-text', headers: { 'content-type': 'text/plain' }, body: 'hello world' });
|
|
236
|
+
test('Text body: returns string', r11c.json().isString === true);
|
|
237
|
+
test('Text body: correct content', r11c.json().parsed === 'hello world');
|
|
238
|
+
|
|
239
|
+
// 11d: req.post() single field
|
|
240
|
+
const r11d = await app.inject({ method: 'POST', url: '/post-field?extra=ignored', headers: { 'content-type': 'application/json' }, body: { name: 'Bob', age: 25 } });
|
|
241
|
+
test('req.post(): name field', r11d.json().name === 'Bob');
|
|
242
|
+
test('req.post(): age field', r11d.json().age === 25);
|
|
243
|
+
|
|
244
|
+
// 11e: req.param() — GET priority, fallback POST
|
|
245
|
+
const r11e_get = await app.inject({ method: 'GET', url: '/param-test?id=99' });
|
|
246
|
+
test('req.param(): GET priority', r11e_get.json().id === '99');
|
|
247
|
+
|
|
248
|
+
const r11e_post = await app.inject({ method: 'POST', url: '/param-test', headers: { 'content-type': 'application/json' }, body: { name: 'Charlie' } });
|
|
249
|
+
test('req.param(): fallback to POST', r11e_post.json().name === 'Charlie');
|
|
250
|
+
|
|
251
|
+
// 11f: req.input() with filter
|
|
252
|
+
const r11f = await app.inject({ method: 'GET', url: '/input-test?age=25' });
|
|
253
|
+
test('req.input(): filter converts to number', r11f.json().age === 25 && r11f.json().ageType === 'number');
|
|
254
|
+
test('req.input(): default value', r11f.json().name === 'default');
|
|
255
|
+
|
|
256
|
+
// 11g: req.getQuery(), req.get(), req.query()
|
|
257
|
+
const r11g = await app.inject({ method: 'GET', url: '/query-test?q=searchterm' });
|
|
258
|
+
test('req.getQuery(): present key', r11g.json().q === 'searchterm');
|
|
259
|
+
|
|
260
|
+
const r11g2 = await app.inject({ method: 'GET', url: '/query-test' });
|
|
261
|
+
test('req.getQuery(): missing key → default', r11g2.json().q === 'none');
|
|
262
|
+
|
|
263
|
+
// 11g2: req.query() overloads and req.get()
|
|
264
|
+
const r11g3 = await app.inject({ method: 'GET', url: '/query-all?a=1&b=2&c=3' });
|
|
265
|
+
const qa = r11g3.json();
|
|
266
|
+
test('req.query(): returns all params', qa.all.a === '1' && qa.all.b === '2' && qa.all.c === '3');
|
|
267
|
+
test('req.query(key): single value', qa.single === '1');
|
|
268
|
+
test('req.get(key): alias works', qa.viaGet === '2');
|
|
269
|
+
test('req.get(key, default): default fallback', qa.viaGetDefault === 'fallback');
|
|
270
|
+
|
|
271
|
+
// 11h: req.files() and req.file() — multipart (simulated raw body)
|
|
272
|
+
const boundary = '----TestBoundary12345';
|
|
273
|
+
const multipartBody = [
|
|
274
|
+
`--${boundary}`,
|
|
275
|
+
'Content-Disposition: form-data; name="username"',
|
|
276
|
+
'',
|
|
277
|
+
'alice',
|
|
278
|
+
`--${boundary}`,
|
|
279
|
+
'Content-Disposition: form-data; name="avatar"; filename="photo.png"',
|
|
280
|
+
'Content-Type: image/png',
|
|
281
|
+
'',
|
|
282
|
+
'fake-image-binary-content',
|
|
283
|
+
`--${boundary}--`,
|
|
284
|
+
''
|
|
285
|
+
].join('\r\n');
|
|
286
|
+
const r11h = await app.inject({
|
|
287
|
+
method: 'POST',
|
|
288
|
+
url: '/upload-test',
|
|
289
|
+
headers: { 'content-type': `multipart/form-data; boundary=${boundary}` },
|
|
290
|
+
body: multipartBody
|
|
291
|
+
});
|
|
292
|
+
test('req.files(): detects fields', r11h.json().fieldCount === 1);
|
|
293
|
+
test('req.files(): detects files', r11h.json().fileCount === 1);
|
|
294
|
+
test('req.files(): correct avatar name', r11h.json().avatarName === 'photo.png');
|
|
295
|
+
test('req.file(): single file found', r11h.json().hasAvatar === true);
|
|
296
|
+
|
|
297
|
+
// 11i: Upload file detail — buffer, size, disk path, content integrity
|
|
298
|
+
const boundary2 = '----TestBoundary67890';
|
|
299
|
+
const fileContent = 'Hello from test file! This is content for upload testing.\nLine two.';
|
|
300
|
+
const multipartBody2 = [
|
|
301
|
+
`--${boundary2}`,
|
|
302
|
+
'Content-Disposition: form-data; name="file"; filename="test-upload.txt"',
|
|
303
|
+
'Content-Type: text/plain',
|
|
304
|
+
'',
|
|
305
|
+
fileContent,
|
|
306
|
+
`--${boundary2}--`,
|
|
307
|
+
''
|
|
308
|
+
].join('\r\n');
|
|
309
|
+
const r11i = await app.inject({
|
|
310
|
+
method: 'POST',
|
|
311
|
+
url: '/upload-detail',
|
|
312
|
+
headers: { 'content-type': `multipart/form-data; boundary=${boundary2}` },
|
|
313
|
+
body: multipartBody2
|
|
314
|
+
});
|
|
315
|
+
const detail = r11i.json();
|
|
316
|
+
test('Upload detail: fieldname', detail.fieldname === 'file');
|
|
317
|
+
test('Upload detail: filename', detail.filename === 'test-upload.txt');
|
|
318
|
+
test('Upload detail: mimetype', detail.mimetype === 'text/plain');
|
|
319
|
+
test('Upload detail: size > 0', detail.size > 0);
|
|
320
|
+
// Small file should be in buffer only (< 512kb), no disk write
|
|
321
|
+
test('Upload detail: has buffer (small file)', detail.hasBuffer === true);
|
|
322
|
+
test('Upload detail: buffer content intact', detail.bufferContent === fileContent);
|
|
323
|
+
// Small files have filepath reference but no actual disk file
|
|
324
|
+
test('Upload detail: has filepath reference', detail.hasPath === true);
|
|
325
|
+
test('Upload detail: no disk write for small file', detail.diskContent === null);
|
|
326
|
+
|
|
327
|
+
// 11j: Same file field appears multiple times → fields array
|
|
328
|
+
const boundary3 = '----TestBoundaryMulti';
|
|
329
|
+
const multipartBody3 = [
|
|
330
|
+
`--${boundary3}`,
|
|
331
|
+
'Content-Disposition: form-data; name="tag"',
|
|
332
|
+
'',
|
|
333
|
+
'red',
|
|
334
|
+
`--${boundary3}`,
|
|
335
|
+
'Content-Disposition: form-data; name="tag"',
|
|
336
|
+
'',
|
|
337
|
+
'blue',
|
|
338
|
+
`--${boundary3}--`,
|
|
339
|
+
''
|
|
340
|
+
].join('\r\n');
|
|
341
|
+
const r11j = await app.inject({
|
|
342
|
+
method: 'POST',
|
|
343
|
+
url: '/upload-test',
|
|
344
|
+
headers: { 'content-type': `multipart/form-data; boundary=${boundary3}` },
|
|
345
|
+
body: multipartBody3
|
|
346
|
+
});
|
|
347
|
+
// fields.tag should be an array since we committed multiple values
|
|
348
|
+
test('Upload multi-field: fieldCount = 1', r11j.json().fieldCount === 1);
|
|
349
|
+
|
|
350
|
+
// ==================== Test 12: response 门面 (前置响应预设) ====================
|
|
351
|
+
console.log('\n[12] response facade (Pre-middleware Headers/Cookies)');
|
|
352
|
+
|
|
353
|
+
const app2 = new Tyno({ debug: false });
|
|
354
|
+
app2.use(async (req: TynoRequest, next: NextFunction) => {
|
|
355
|
+
response.header('X-Pre-Header', 'from-pre');
|
|
356
|
+
response.setCookie('pre_cookie', 'pre_value', { httpOnly: true });
|
|
357
|
+
const res = await next();
|
|
358
|
+
return res;
|
|
359
|
+
});
|
|
360
|
+
app2.use(async () => {
|
|
361
|
+
return Response.json({ ok: true });
|
|
362
|
+
});
|
|
363
|
+
|
|
364
|
+
const r12a = await app2.inject({ method: 'GET', url: '/' });
|
|
365
|
+
test('response facade: header merged', String(r12a.headers['x-pre-header'] || '') === 'from-pre');
|
|
366
|
+
test('response facade: cookie merged', String(r12a.headers['set-cookie'] || '').includes('pre_cookie=pre_value'));
|
|
367
|
+
|
|
368
|
+
// Test that final response overrides facade
|
|
369
|
+
const app3 = new Tyno({ debug: false });
|
|
370
|
+
app3.use(async (req: TynoRequest, next: NextFunction) => {
|
|
371
|
+
response.header('X-Header', 'facade-value');
|
|
372
|
+
const res = await next();
|
|
373
|
+
return res;
|
|
374
|
+
});
|
|
375
|
+
app3.use(async () => {
|
|
376
|
+
return Response.json({ ok: true }).set('X-Header', 'final-value');
|
|
377
|
+
});
|
|
378
|
+
|
|
379
|
+
const r12b = await app3.inject({ method: 'GET', url: '/' });
|
|
380
|
+
test('response facade: final overrides facade header', String(r12b.headers['x-header'] || '') === 'final-value');
|
|
381
|
+
|
|
382
|
+
// Test facade with error
|
|
383
|
+
const app4 = new Tyno({ debug: false });
|
|
384
|
+
app4.use(async (req: TynoRequest, next: NextFunction) => {
|
|
385
|
+
response.header('X-Error-Header', 'still-there');
|
|
386
|
+
return next();
|
|
387
|
+
});
|
|
388
|
+
app4.use(async () => {
|
|
389
|
+
throw new HttpError(400, 'bad');
|
|
390
|
+
});
|
|
391
|
+
|
|
392
|
+
const r12c = await app4.inject({ method: 'GET', url: '/' });
|
|
393
|
+
test('response facade: header present on error response', String(r12c.headers['x-error-header'] || '') === 'still-there');
|
|
394
|
+
|
|
395
|
+
// ==================== Test 13: Response.image() ====================
|
|
396
|
+
console.log('\n[13] Response.image()');
|
|
397
|
+
|
|
398
|
+
const fakePng = Buffer.from([0x89, 0x50, 0x4E, 0x47, 0x0D, 0x0A, 0x1A, 0x0A]); // PNG signature
|
|
399
|
+
const r13a = Response.image(fakePng, 'png');
|
|
400
|
+
test('Response.image(): content-type is image/png', r13a.get('Content-Type') === 'image/png');
|
|
401
|
+
test('Response.image(): body is buffer', r13a.body === fakePng);
|
|
402
|
+
test('Response.image(): status 200', r13a.status === 200);
|
|
403
|
+
|
|
404
|
+
const r13b = Response.image(Buffer.from('fake-jpeg'), 'image/jpeg', 201);
|
|
405
|
+
test('Response.image(): custom mime preserved', r13b.get('Content-Type') === 'image/jpeg');
|
|
406
|
+
test('Response.image(): custom status', r13b.status === 201);
|
|
407
|
+
|
|
408
|
+
const r13c = Response.image(Buffer.from('svg-data'), 'svg');
|
|
409
|
+
test('Response.image(): shorthand looks up MIME', r13c.get('Content-Type') === 'image/svg+xml');
|
|
410
|
+
|
|
411
|
+
// ==================== Test 14: EventEmitter 事件系统 ====================
|
|
412
|
+
console.log('\n[14] EventEmitter 事件系统');
|
|
413
|
+
|
|
414
|
+
const app5 = new Tyno({ debug: false });
|
|
415
|
+
const events: string[] = [];
|
|
416
|
+
|
|
417
|
+
app5.on('request', (req: TynoRequest) => {
|
|
418
|
+
events.push('request:' + req.path);
|
|
419
|
+
});
|
|
420
|
+
app5.on('response', (req: TynoRequest, res: Response) => {
|
|
421
|
+
events.push('response:' + res.status);
|
|
422
|
+
});
|
|
423
|
+
app5.on('error', (err: unknown, req: TynoRequest) => {
|
|
424
|
+
events.push('error:' + (err as { status?: number }).status);
|
|
425
|
+
});
|
|
426
|
+
app5.use(async (req: TynoRequest) => {
|
|
427
|
+
if (req.path === '/fail') throw new HttpError(400, 'bad');
|
|
428
|
+
return Response.json({ ok: true });
|
|
429
|
+
});
|
|
430
|
+
|
|
431
|
+
const r14a = await app5.inject({ method: 'GET', url: '/events-test' });
|
|
432
|
+
test('Events: request event fired', events.some(e => e === 'request:/events-test'));
|
|
433
|
+
test('Events: response event fired', events.some(e => e === 'response:200'));
|
|
434
|
+
|
|
435
|
+
events.length = 0;
|
|
436
|
+
await app5.inject({ method: 'GET', url: '/fail' });
|
|
437
|
+
test('Events: error event fired on HttpError', events.some(e => e === 'error:400'));
|
|
438
|
+
|
|
439
|
+
// ready event test
|
|
440
|
+
const app6 = new Tyno({ debug: false });
|
|
441
|
+
let readyFired = false;
|
|
442
|
+
app6.on('ready', () => { readyFired = true; });
|
|
443
|
+
app6.use(() => 'ok');
|
|
444
|
+
const server = app6.listen(0, () => {
|
|
445
|
+
app6.close();
|
|
446
|
+
});
|
|
447
|
+
await new Promise<void>(resolve => setTimeout(resolve, 100));
|
|
448
|
+
test('Events: ready event fired on listen', readyFired);
|
|
449
|
+
|
|
450
|
+
// ==================== Test 15: Cache 内存驱动 ====================
|
|
451
|
+
console.log('\n[15] Cache (Memory Driver)');
|
|
452
|
+
|
|
453
|
+
const app7 = new Tyno({ debug: false, cache: { driver: 'memory', prefix: 'test:', ttl: 5 } });
|
|
454
|
+
const c = app7.cache();
|
|
455
|
+
|
|
456
|
+
await c.set('name', 'Alice');
|
|
457
|
+
test('Cache memory: set + get', (await c.get('name')) === 'Alice');
|
|
458
|
+
|
|
459
|
+
test('Cache memory: has', await c.has('name'));
|
|
460
|
+
test('Cache memory: has not', !(await c.has('not_exists')));
|
|
461
|
+
|
|
462
|
+
await c.set('obj', { x: 1, y: [2, 3] });
|
|
463
|
+
const obj = await c.get<{ x: number; y: number[] }>('obj');
|
|
464
|
+
test('Cache memory: JSON object', obj?.x === 1 && obj?.y[1] === 3);
|
|
465
|
+
|
|
466
|
+
await c.delete('name');
|
|
467
|
+
test('Cache memory: delete', (await c.get('name')) === null);
|
|
468
|
+
|
|
469
|
+
await c.set('a', 1);
|
|
470
|
+
await c.set('b', 2);
|
|
471
|
+
await c.clear();
|
|
472
|
+
test('Cache memory: clear', (await c.get('a')) === null && (await c.get('b')) === null);
|
|
473
|
+
|
|
474
|
+
// TTL 过期测试
|
|
475
|
+
await c.set('expiring', 'value', 1); // 1 秒 TTL
|
|
476
|
+
test('Cache memory: before TTL', (await c.get('expiring')) === 'value');
|
|
477
|
+
await new Promise<void>(resolve => setTimeout(resolve, 1100));
|
|
478
|
+
test('Cache memory: after TTL expires', (await c.get('expiring')) === null);
|
|
479
|
+
|
|
480
|
+
// remember 测试
|
|
481
|
+
let callCount = 0;
|
|
482
|
+
const result1 = await c.remember('computed', 10, () => { callCount++; return 42; });
|
|
483
|
+
const result2 = await c.remember('computed', 10, () => { callCount++; return 99; });
|
|
484
|
+
test('Cache memory: remember calls factory once', result1 === 42 && result2 === 42 && callCount === 1);
|
|
485
|
+
|
|
486
|
+
// ==================== Test 16: Cache 文件驱动 ====================
|
|
487
|
+
console.log('\n[16] Cache (File Driver)');
|
|
488
|
+
|
|
489
|
+
const app8 = new Tyno({ debug: false, cache: { driver: 'file', file: { path: '/tmp/tyno-cache-test' } } });
|
|
490
|
+
const cf = app8.cache();
|
|
491
|
+
|
|
492
|
+
await cf.set('file_key', 'file_value');
|
|
493
|
+
test('Cache file: set + get', (await cf.get('file_key')) === 'file_value');
|
|
494
|
+
|
|
495
|
+
await cf.set('file_obj', { hello: 'world' });
|
|
496
|
+
const fobj = await cf.get<{ hello: string }>('file_obj');
|
|
497
|
+
test('Cache file: JSON object', fobj?.hello === 'world');
|
|
498
|
+
|
|
499
|
+
test('Cache file: has', await cf.has('file_key'));
|
|
500
|
+
await cf.delete('file_key');
|
|
501
|
+
test('Cache file: delete', !(await cf.has('file_key')));
|
|
502
|
+
|
|
503
|
+
await cf.set('file_clear', 1);
|
|
504
|
+
await cf.clear();
|
|
505
|
+
test('Cache file: clear', !(await cf.has('file_clear')));
|
|
506
|
+
|
|
507
|
+
// TTL 过期
|
|
508
|
+
await cf.set('file_exp', 'val', 1);
|
|
509
|
+
await new Promise<void>(resolve => setTimeout(resolve, 1100));
|
|
510
|
+
test('Cache file: TTL expiry', (await cf.get('file_exp')) === null);
|
|
511
|
+
|
|
512
|
+
// ==================== Test 17: Redis 驱动(未安装时的错误) ====================
|
|
513
|
+
console.log('\n[17] Cache (Redis - not installed)');
|
|
514
|
+
|
|
515
|
+
const cacheRedis = new CacheManager({ driver: 'redis', redis: { host: '127.0.0.1' } });
|
|
516
|
+
try {
|
|
517
|
+
await cacheRedis.get('key');
|
|
518
|
+
test('Redis: throws on missing redis package', false);
|
|
519
|
+
} catch (e: unknown) {
|
|
520
|
+
test('Redis: throws on missing redis package', (e as Error).message.includes('redis'));
|
|
521
|
+
}
|
|
522
|
+
|
|
523
|
+
// ==================== Test 18: Cache 门面 ====================
|
|
524
|
+
console.log('\n[18] Cache Facade (全局门面)');
|
|
525
|
+
|
|
526
|
+
const appCacheFacade = new Tyno({ debug: false, cache: { driver: 'memory', prefix: 'facade:', ttl: 30 } });
|
|
527
|
+
appCacheFacade.use(async (req: TynoRequest) => {
|
|
528
|
+
if (req.path === '/facade-set') {
|
|
529
|
+
await cache.set('greeting', 'hello from facade', 60);
|
|
530
|
+
return Response.json({ ok: true });
|
|
531
|
+
}
|
|
532
|
+
if (req.path === '/facade-get') {
|
|
533
|
+
const val = await cache.get<string>('greeting');
|
|
534
|
+
return Response.json({ value: val });
|
|
535
|
+
}
|
|
536
|
+
if (req.path === '/facade-remember') {
|
|
537
|
+
let callN = 0;
|
|
538
|
+
const val = await cache.remember('computed', 60, () => { callN++; return 'remembered'; });
|
|
539
|
+
const val2 = await cache.remember('computed', 60, () => { callN++; return 'not called'; });
|
|
540
|
+
return Response.json({ val, val2, callN });
|
|
541
|
+
}
|
|
542
|
+
return Response.json({ ok: true });
|
|
543
|
+
});
|
|
544
|
+
|
|
545
|
+
await appCacheFacade.inject({ method: 'GET', url: '/facade-set' });
|
|
546
|
+
const r18a = await appCacheFacade.inject({ method: 'GET', url: '/facade-get' });
|
|
547
|
+
test('Cache facade: set + get via global facade', r18a.json().value === 'hello from facade');
|
|
548
|
+
|
|
549
|
+
const r18b = await appCacheFacade.inject({ method: 'GET', url: '/facade-remember' });
|
|
550
|
+
const rb = r18b.json();
|
|
551
|
+
test('Cache facade: remember works via facade', rb.val === 'remembered' && rb.val2 === 'remembered' && rb.callN === 1);
|
|
552
|
+
|
|
553
|
+
// Test: facade throws outside request context
|
|
554
|
+
try {
|
|
555
|
+
await cache.get('key');
|
|
556
|
+
test('Cache facade: throws outside request', false);
|
|
557
|
+
} catch (e: unknown) {
|
|
558
|
+
test('Cache facade: throws outside request', (e as Error).message.includes('No active request'));
|
|
559
|
+
}
|
|
560
|
+
|
|
561
|
+
// ==================== Test 19: Application extends EventEmitter ====================
|
|
562
|
+
console.log('\n[19] Application instanceof EventEmitter');
|
|
563
|
+
|
|
564
|
+
const app9 = new Tyno();
|
|
565
|
+
test('Application: extends EventEmitter', typeof app9.on === 'function' && typeof app9.emit === 'function');
|
|
566
|
+
test('Application: app.on returns this', app9.on('test', () => {}) === app9);
|
|
567
|
+
|
|
568
|
+
// ==================== Test 20: response:sent 事件 ====================
|
|
569
|
+
console.log('\n[20] response:sent Event');
|
|
570
|
+
|
|
571
|
+
const app10 = new Tyno({ debug: false });
|
|
572
|
+
let sentCount = 0;
|
|
573
|
+
app10.on('response:sent', (req: TynoRequest, res: Response) => { sentCount++; });
|
|
574
|
+
app10.use(() => 'ok');
|
|
575
|
+
await app10.inject({ method: 'GET', url: '/' });
|
|
576
|
+
test('response:sent: fires after send', sentCount === 1);
|
|
577
|
+
|
|
578
|
+
// ==================== Test 21: Router group() + fallback() ====================
|
|
579
|
+
console.log('\n[21] Router group() + fallback()');
|
|
580
|
+
|
|
581
|
+
const r2 = new Router();
|
|
582
|
+
r2.group('/admin', (admin) => {
|
|
583
|
+
admin.get('/dashboard', () => 'dashboard');
|
|
584
|
+
admin.get('/users', () => 'users');
|
|
585
|
+
});
|
|
586
|
+
r2.fallback(() => Response.json({ err: 'nf' }, 404));
|
|
587
|
+
|
|
588
|
+
const app11 = new Tyno({ debug: false });
|
|
589
|
+
app11.use(r2.routes());
|
|
590
|
+
const r21a = await app11.inject({ method: 'GET', url: '/admin/dashboard' });
|
|
591
|
+
test('Router group: nested route matches', r21a.body === 'dashboard');
|
|
592
|
+
const r21b = await app11.inject({ method: 'GET', url: '/admin/users' });
|
|
593
|
+
test('Router group: second route matches', r21b.body === 'users');
|
|
594
|
+
const r21c = await app11.inject({ method: 'GET', url: '/nope' });
|
|
595
|
+
test('Router fallback: unmatched route', r21c.status === 404 && r21c.json().err === 'nf');
|
|
596
|
+
|
|
597
|
+
// ==================== Test 22: req/res/Cache 别名 ====================
|
|
598
|
+
console.log('\n[22] Aliases: req / res / Cache');
|
|
599
|
+
|
|
600
|
+
const { req: ReqClass, res: ResAlias } = await import('../src/index.ts');
|
|
601
|
+
test('Alias: req === Request', ReqClass.name === 'Request' || ReqClass === ReqClass);
|
|
602
|
+
test('Alias: res === Response', ResAlias === Response);
|
|
603
|
+
// Cache alias
|
|
604
|
+
const { Cache: CacheFacade } = await import('../src/facade/index.ts');
|
|
605
|
+
test('Alias: Cache facade exists', typeof CacheFacade === 'object' && typeof CacheFacade.get === 'function');
|
|
606
|
+
|
|
607
|
+
console.log(`\n=== Results: ${passed} passed, ${failed} failed ===`);
|
|
608
|
+
if (failed > 0) process.exit(1);
|
|
609
|
+
}
|
|
610
|
+
|
|
611
|
+
main().catch(err => {
|
|
612
|
+
console.error('Test suite failed:', err);
|
|
613
|
+
process.exit(1);
|
|
614
|
+
});
|
|
@@ -0,0 +1,13 @@
|
|
|
1
|
+
{
|
|
2
|
+
"extends": "./tsconfig.json",
|
|
3
|
+
"compilerOptions": {
|
|
4
|
+
"noEmit": false,
|
|
5
|
+
"emitDeclarationOnly": true,
|
|
6
|
+
"declaration": true,
|
|
7
|
+
"declarationMap": true,
|
|
8
|
+
"outDir": "./dist",
|
|
9
|
+
"rootDir": "./src"
|
|
10
|
+
},
|
|
11
|
+
"include": ["src/**/*"],
|
|
12
|
+
"exclude": ["node_modules", "dist", "test", "example"]
|
|
13
|
+
}
|
package/tsconfig.json
ADDED
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
{
|
|
2
|
+
"compilerOptions": {
|
|
3
|
+
"target": "ES2022",
|
|
4
|
+
"module": "ESNext",
|
|
5
|
+
"moduleResolution": "bundler",
|
|
6
|
+
"outDir": "./dist",
|
|
7
|
+
"rootDir": ".",
|
|
8
|
+
"strict": true,
|
|
9
|
+
"esModuleInterop": true,
|
|
10
|
+
"skipLibCheck": true,
|
|
11
|
+
"forceConsistentCasingInFileNames": true,
|
|
12
|
+
"declaration": true,
|
|
13
|
+
"declarationMap": true,
|
|
14
|
+
"sourceMap": true,
|
|
15
|
+
"resolveJsonModule": true,
|
|
16
|
+
"allowImportingTsExtensions": true,
|
|
17
|
+
"noEmit": true
|
|
18
|
+
},
|
|
19
|
+
"include": ["src/**/*", "example/**/*"],
|
|
20
|
+
"exclude": ["node_modules", "dist"]
|
|
21
|
+
}
|