@ranimontagna/agent-toolkit 0.1.4 → 0.1.5
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 +282 -277
- package/docs/assets/install-plan.svg +29 -0
- package/docs/assets/install-skill-packages.svg +31 -0
- package/docs/assets/install-status.svg +32 -0
- package/package.json +10 -9
- package/setup-agent-toolkit.sh +1 -1
- package/skills/backend/fastify-best-practices/LICENSE +21 -0
- package/skills/backend/fastify-best-practices/NOTICE.md +11 -0
- package/skills/backend/fastify-best-practices/SKILL.md +75 -0
- package/skills/backend/fastify-best-practices/rules/authentication.md +521 -0
- package/skills/backend/fastify-best-practices/rules/configuration.md +217 -0
- package/skills/backend/fastify-best-practices/rules/content-type.md +387 -0
- package/skills/backend/fastify-best-practices/rules/cors-security.md +445 -0
- package/skills/backend/fastify-best-practices/rules/database.md +320 -0
- package/skills/backend/fastify-best-practices/rules/decorators.md +416 -0
- package/skills/backend/fastify-best-practices/rules/deployment.md +423 -0
- package/skills/backend/fastify-best-practices/rules/error-handling.md +412 -0
- package/skills/backend/fastify-best-practices/rules/hooks.md +464 -0
- package/skills/backend/fastify-best-practices/rules/http-proxy.md +247 -0
- package/skills/backend/fastify-best-practices/rules/logging.md +402 -0
- package/skills/backend/fastify-best-practices/rules/performance.md +425 -0
- package/skills/backend/fastify-best-practices/rules/plugins.md +320 -0
- package/skills/backend/fastify-best-practices/rules/routes.md +467 -0
- package/skills/backend/fastify-best-practices/rules/schemas.md +585 -0
- package/skills/backend/fastify-best-practices/rules/serialization.md +475 -0
- package/skills/backend/fastify-best-practices/rules/testing.md +536 -0
- package/skills/backend/fastify-best-practices/rules/typescript.md +458 -0
- package/skills/backend/fastify-best-practices/rules/websockets.md +421 -0
- package/skills/backend/fastify-best-practices/tile.json +11 -0
- package/skills/core/agent-toolkit-maintainer/SKILL.md +16 -14
|
@@ -0,0 +1,536 @@
|
|
|
1
|
+
---
|
|
2
|
+
name: testing
|
|
3
|
+
description: Testing Fastify applications with inject()
|
|
4
|
+
metadata:
|
|
5
|
+
tags: testing, inject, node-test, integration, unit
|
|
6
|
+
---
|
|
7
|
+
|
|
8
|
+
# Testing Fastify Applications
|
|
9
|
+
|
|
10
|
+
## Using inject() for Request Testing
|
|
11
|
+
|
|
12
|
+
Fastify's `inject()` method simulates HTTP requests without network overhead:
|
|
13
|
+
|
|
14
|
+
```typescript
|
|
15
|
+
import { describe, it, before, after } from 'node:test';
|
|
16
|
+
import Fastify from 'fastify';
|
|
17
|
+
import { buildApp } from './app.js';
|
|
18
|
+
|
|
19
|
+
describe('User API', () => {
|
|
20
|
+
let app;
|
|
21
|
+
|
|
22
|
+
before(async () => {
|
|
23
|
+
app = await buildApp();
|
|
24
|
+
await app.ready();
|
|
25
|
+
});
|
|
26
|
+
|
|
27
|
+
after(async () => {
|
|
28
|
+
await app.close();
|
|
29
|
+
});
|
|
30
|
+
|
|
31
|
+
it('should return users list', async (t) => {
|
|
32
|
+
const response = await app.inject({
|
|
33
|
+
method: 'GET',
|
|
34
|
+
url: '/users',
|
|
35
|
+
});
|
|
36
|
+
|
|
37
|
+
t.assert.equal(response.statusCode, 200);
|
|
38
|
+
t.assert.equal(response.headers['content-type'], 'application/json; charset=utf-8');
|
|
39
|
+
|
|
40
|
+
const body = response.json();
|
|
41
|
+
t.assert.ok(Array.isArray(body.users));
|
|
42
|
+
});
|
|
43
|
+
|
|
44
|
+
it('should create a user', async (t) => {
|
|
45
|
+
const response = await app.inject({
|
|
46
|
+
method: 'POST',
|
|
47
|
+
url: '/users',
|
|
48
|
+
payload: {
|
|
49
|
+
name: 'John Doe',
|
|
50
|
+
email: 'john@example.com',
|
|
51
|
+
},
|
|
52
|
+
});
|
|
53
|
+
|
|
54
|
+
t.assert.equal(response.statusCode, 201);
|
|
55
|
+
|
|
56
|
+
const body = response.json();
|
|
57
|
+
t.assert.equal(body.name, 'John Doe');
|
|
58
|
+
t.assert.ok(body.id);
|
|
59
|
+
});
|
|
60
|
+
});
|
|
61
|
+
```
|
|
62
|
+
|
|
63
|
+
## Testing with Headers and Authentication
|
|
64
|
+
|
|
65
|
+
Test authenticated endpoints:
|
|
66
|
+
|
|
67
|
+
```typescript
|
|
68
|
+
describe('Protected Routes', () => {
|
|
69
|
+
let app;
|
|
70
|
+
let authToken;
|
|
71
|
+
|
|
72
|
+
before(async () => {
|
|
73
|
+
app = await buildApp();
|
|
74
|
+
await app.ready();
|
|
75
|
+
|
|
76
|
+
// Get auth token
|
|
77
|
+
const loginResponse = await app.inject({
|
|
78
|
+
method: 'POST',
|
|
79
|
+
url: '/auth/login',
|
|
80
|
+
payload: {
|
|
81
|
+
email: 'test@example.com',
|
|
82
|
+
password: 'password123',
|
|
83
|
+
},
|
|
84
|
+
});
|
|
85
|
+
|
|
86
|
+
authToken = loginResponse.json().token;
|
|
87
|
+
});
|
|
88
|
+
|
|
89
|
+
after(async () => {
|
|
90
|
+
await app.close();
|
|
91
|
+
});
|
|
92
|
+
|
|
93
|
+
it('should reject unauthenticated requests', async (t) => {
|
|
94
|
+
const response = await app.inject({
|
|
95
|
+
method: 'GET',
|
|
96
|
+
url: '/profile',
|
|
97
|
+
});
|
|
98
|
+
|
|
99
|
+
t.assert.equal(response.statusCode, 401);
|
|
100
|
+
});
|
|
101
|
+
|
|
102
|
+
it('should return profile for authenticated user', async (t) => {
|
|
103
|
+
const response = await app.inject({
|
|
104
|
+
method: 'GET',
|
|
105
|
+
url: '/profile',
|
|
106
|
+
headers: {
|
|
107
|
+
authorization: `Bearer ${authToken}`,
|
|
108
|
+
},
|
|
109
|
+
});
|
|
110
|
+
|
|
111
|
+
t.assert.equal(response.statusCode, 200);
|
|
112
|
+
t.assert.equal(response.json().email, 'test@example.com');
|
|
113
|
+
});
|
|
114
|
+
});
|
|
115
|
+
```
|
|
116
|
+
|
|
117
|
+
## Testing Query Parameters
|
|
118
|
+
|
|
119
|
+
Test routes with query strings:
|
|
120
|
+
|
|
121
|
+
```typescript
|
|
122
|
+
it('should filter users by status', async (t) => {
|
|
123
|
+
const response = await app.inject({
|
|
124
|
+
method: 'GET',
|
|
125
|
+
url: '/users',
|
|
126
|
+
query: {
|
|
127
|
+
status: 'active',
|
|
128
|
+
page: '1',
|
|
129
|
+
limit: '10',
|
|
130
|
+
},
|
|
131
|
+
});
|
|
132
|
+
|
|
133
|
+
t.assert.equal(response.statusCode, 200);
|
|
134
|
+
const body = response.json();
|
|
135
|
+
t.assert.ok(body.users.every((u) => u.status === 'active'));
|
|
136
|
+
});
|
|
137
|
+
|
|
138
|
+
// Or use URL with query string
|
|
139
|
+
it('should search users', async (t) => {
|
|
140
|
+
const response = await app.inject({
|
|
141
|
+
method: 'GET',
|
|
142
|
+
url: '/users?q=john&sort=name',
|
|
143
|
+
});
|
|
144
|
+
|
|
145
|
+
t.assert.equal(response.statusCode, 200);
|
|
146
|
+
});
|
|
147
|
+
```
|
|
148
|
+
|
|
149
|
+
## Testing URL Parameters
|
|
150
|
+
|
|
151
|
+
Test routes with path parameters:
|
|
152
|
+
|
|
153
|
+
```typescript
|
|
154
|
+
it('should return user by id', async (t) => {
|
|
155
|
+
const userId = 'user-123';
|
|
156
|
+
|
|
157
|
+
const response = await app.inject({
|
|
158
|
+
method: 'GET',
|
|
159
|
+
url: `/users/${userId}`,
|
|
160
|
+
});
|
|
161
|
+
|
|
162
|
+
t.assert.equal(response.statusCode, 200);
|
|
163
|
+
t.assert.equal(response.json().id, userId);
|
|
164
|
+
});
|
|
165
|
+
|
|
166
|
+
it('should return 404 for non-existent user', async (t) => {
|
|
167
|
+
const response = await app.inject({
|
|
168
|
+
method: 'GET',
|
|
169
|
+
url: '/users/non-existent',
|
|
170
|
+
});
|
|
171
|
+
|
|
172
|
+
t.assert.equal(response.statusCode, 404);
|
|
173
|
+
});
|
|
174
|
+
```
|
|
175
|
+
|
|
176
|
+
## Testing Validation Errors
|
|
177
|
+
|
|
178
|
+
Test schema validation:
|
|
179
|
+
|
|
180
|
+
```typescript
|
|
181
|
+
describe('Validation', () => {
|
|
182
|
+
it('should reject invalid email', async (t) => {
|
|
183
|
+
const response = await app.inject({
|
|
184
|
+
method: 'POST',
|
|
185
|
+
url: '/users',
|
|
186
|
+
payload: {
|
|
187
|
+
name: 'John',
|
|
188
|
+
email: 'not-an-email',
|
|
189
|
+
},
|
|
190
|
+
});
|
|
191
|
+
|
|
192
|
+
t.assert.equal(response.statusCode, 400);
|
|
193
|
+
const body = response.json();
|
|
194
|
+
t.assert.ok(body.message.includes('email'));
|
|
195
|
+
});
|
|
196
|
+
|
|
197
|
+
it('should reject missing required fields', async (t) => {
|
|
198
|
+
const response = await app.inject({
|
|
199
|
+
method: 'POST',
|
|
200
|
+
url: '/users',
|
|
201
|
+
payload: {
|
|
202
|
+
name: 'John',
|
|
203
|
+
// missing email
|
|
204
|
+
},
|
|
205
|
+
});
|
|
206
|
+
|
|
207
|
+
t.assert.equal(response.statusCode, 400);
|
|
208
|
+
});
|
|
209
|
+
|
|
210
|
+
it('should coerce query parameters', async (t) => {
|
|
211
|
+
const response = await app.inject({
|
|
212
|
+
method: 'GET',
|
|
213
|
+
url: '/items?limit=10&active=true',
|
|
214
|
+
});
|
|
215
|
+
|
|
216
|
+
t.assert.equal(response.statusCode, 200);
|
|
217
|
+
// limit is coerced to number, active to boolean
|
|
218
|
+
});
|
|
219
|
+
});
|
|
220
|
+
```
|
|
221
|
+
|
|
222
|
+
## Testing File Uploads
|
|
223
|
+
|
|
224
|
+
Test multipart form data:
|
|
225
|
+
|
|
226
|
+
```typescript
|
|
227
|
+
import { createReadStream } from 'node:fs';
|
|
228
|
+
import FormData from 'form-data';
|
|
229
|
+
|
|
230
|
+
it('should upload file', async (t) => {
|
|
231
|
+
const form = new FormData();
|
|
232
|
+
form.append('file', createReadStream('./test/fixtures/test.pdf'));
|
|
233
|
+
form.append('name', 'test-document');
|
|
234
|
+
|
|
235
|
+
const response = await app.inject({
|
|
236
|
+
method: 'POST',
|
|
237
|
+
url: '/upload',
|
|
238
|
+
payload: form,
|
|
239
|
+
headers: form.getHeaders(),
|
|
240
|
+
});
|
|
241
|
+
|
|
242
|
+
t.assert.equal(response.statusCode, 200);
|
|
243
|
+
t.assert.ok(response.json().fileId);
|
|
244
|
+
});
|
|
245
|
+
```
|
|
246
|
+
|
|
247
|
+
## Testing Streams
|
|
248
|
+
|
|
249
|
+
Test streaming responses:
|
|
250
|
+
|
|
251
|
+
```typescript
|
|
252
|
+
it('should stream large file', async (t) => {
|
|
253
|
+
const response = await app.inject({
|
|
254
|
+
method: 'GET',
|
|
255
|
+
url: '/files/large-file',
|
|
256
|
+
});
|
|
257
|
+
|
|
258
|
+
t.assert.equal(response.statusCode, 200);
|
|
259
|
+
t.assert.ok(response.rawPayload.length > 0);
|
|
260
|
+
});
|
|
261
|
+
```
|
|
262
|
+
|
|
263
|
+
## Mocking Dependencies
|
|
264
|
+
|
|
265
|
+
Mock external services and databases:
|
|
266
|
+
|
|
267
|
+
```typescript
|
|
268
|
+
import { describe, it, before, after, mock } from 'node:test';
|
|
269
|
+
|
|
270
|
+
describe('User Service', () => {
|
|
271
|
+
let app;
|
|
272
|
+
|
|
273
|
+
before(async () => {
|
|
274
|
+
// Create app with mocked dependencies
|
|
275
|
+
const mockDb = {
|
|
276
|
+
users: {
|
|
277
|
+
findAll: mock.fn(async () => [
|
|
278
|
+
{ id: '1', name: 'User 1' },
|
|
279
|
+
{ id: '2', name: 'User 2' },
|
|
280
|
+
]),
|
|
281
|
+
findById: mock.fn(async (id) => {
|
|
282
|
+
if (id === '1') return { id: '1', name: 'User 1' };
|
|
283
|
+
return null;
|
|
284
|
+
}),
|
|
285
|
+
create: mock.fn(async (data) => ({ id: 'new-id', ...data })),
|
|
286
|
+
},
|
|
287
|
+
};
|
|
288
|
+
|
|
289
|
+
app = Fastify();
|
|
290
|
+
app.decorate('db', mockDb);
|
|
291
|
+
app.register(import('./routes/users.js'));
|
|
292
|
+
await app.ready();
|
|
293
|
+
});
|
|
294
|
+
|
|
295
|
+
after(async () => {
|
|
296
|
+
await app.close();
|
|
297
|
+
});
|
|
298
|
+
|
|
299
|
+
it('should call findAll', async (t) => {
|
|
300
|
+
const response = await app.inject({
|
|
301
|
+
method: 'GET',
|
|
302
|
+
url: '/users',
|
|
303
|
+
});
|
|
304
|
+
|
|
305
|
+
t.assert.equal(response.statusCode, 200);
|
|
306
|
+
t.assert.equal(app.db.users.findAll.mock.calls.length, 1);
|
|
307
|
+
});
|
|
308
|
+
});
|
|
309
|
+
```
|
|
310
|
+
|
|
311
|
+
## Testing Plugins in Isolation
|
|
312
|
+
|
|
313
|
+
Test plugins independently:
|
|
314
|
+
|
|
315
|
+
```typescript
|
|
316
|
+
import { describe, it, before, after } from 'node:test';
|
|
317
|
+
import Fastify from 'fastify';
|
|
318
|
+
import cachePlugin from './plugins/cache.js';
|
|
319
|
+
|
|
320
|
+
describe('Cache Plugin', () => {
|
|
321
|
+
let app;
|
|
322
|
+
|
|
323
|
+
before(async () => {
|
|
324
|
+
app = Fastify();
|
|
325
|
+
app.register(cachePlugin, { ttl: 1000 });
|
|
326
|
+
await app.ready();
|
|
327
|
+
});
|
|
328
|
+
|
|
329
|
+
after(async () => {
|
|
330
|
+
await app.close();
|
|
331
|
+
});
|
|
332
|
+
|
|
333
|
+
it('should decorate fastify with cache', (t) => {
|
|
334
|
+
t.assert.ok(app.hasDecorator('cache'));
|
|
335
|
+
t.assert.equal(typeof app.cache.get, 'function');
|
|
336
|
+
t.assert.equal(typeof app.cache.set, 'function');
|
|
337
|
+
});
|
|
338
|
+
|
|
339
|
+
it('should cache and retrieve values', (t) => {
|
|
340
|
+
app.cache.set('key', 'value');
|
|
341
|
+
t.assert.equal(app.cache.get('key'), 'value');
|
|
342
|
+
});
|
|
343
|
+
});
|
|
344
|
+
```
|
|
345
|
+
|
|
346
|
+
## Testing Hooks
|
|
347
|
+
|
|
348
|
+
Test hook behavior:
|
|
349
|
+
|
|
350
|
+
```typescript
|
|
351
|
+
describe('Hooks', () => {
|
|
352
|
+
it('should add request id header', async (t) => {
|
|
353
|
+
const response = await app.inject({
|
|
354
|
+
method: 'GET',
|
|
355
|
+
url: '/health',
|
|
356
|
+
});
|
|
357
|
+
|
|
358
|
+
t.assert.ok(response.headers['x-request-id']);
|
|
359
|
+
});
|
|
360
|
+
|
|
361
|
+
it('should log request timing', async (t) => {
|
|
362
|
+
const logs = [];
|
|
363
|
+
const app = Fastify({
|
|
364
|
+
logger: {
|
|
365
|
+
level: 'info',
|
|
366
|
+
stream: {
|
|
367
|
+
write: (msg) => logs.push(JSON.parse(msg)),
|
|
368
|
+
},
|
|
369
|
+
},
|
|
370
|
+
});
|
|
371
|
+
|
|
372
|
+
app.register(import('./app.js'));
|
|
373
|
+
await app.ready();
|
|
374
|
+
|
|
375
|
+
await app.inject({ method: 'GET', url: '/health' });
|
|
376
|
+
|
|
377
|
+
const responseLog = logs.find((l) => l.msg?.includes('completed'));
|
|
378
|
+
t.assert.ok(responseLog);
|
|
379
|
+
t.assert.ok(responseLog.responseTime);
|
|
380
|
+
|
|
381
|
+
await app.close();
|
|
382
|
+
});
|
|
383
|
+
});
|
|
384
|
+
```
|
|
385
|
+
|
|
386
|
+
## Test Factory Pattern
|
|
387
|
+
|
|
388
|
+
Create a reusable test app builder:
|
|
389
|
+
|
|
390
|
+
```typescript
|
|
391
|
+
// test/helper.ts
|
|
392
|
+
import Fastify from 'fastify';
|
|
393
|
+
import type { FastifyInstance } from 'fastify';
|
|
394
|
+
|
|
395
|
+
interface TestContext {
|
|
396
|
+
app: FastifyInstance;
|
|
397
|
+
inject: FastifyInstance['inject'];
|
|
398
|
+
}
|
|
399
|
+
|
|
400
|
+
export async function buildTestApp(options = {}): Promise<TestContext> {
|
|
401
|
+
const app = Fastify({
|
|
402
|
+
logger: false, // Disable logging in tests
|
|
403
|
+
...options,
|
|
404
|
+
});
|
|
405
|
+
|
|
406
|
+
// Register plugins
|
|
407
|
+
app.register(import('../src/plugins/database.js'), {
|
|
408
|
+
connectionString: process.env.TEST_DATABASE_URL,
|
|
409
|
+
});
|
|
410
|
+
app.register(import('../src/routes/index.js'));
|
|
411
|
+
|
|
412
|
+
await app.ready();
|
|
413
|
+
|
|
414
|
+
return {
|
|
415
|
+
app,
|
|
416
|
+
inject: app.inject.bind(app),
|
|
417
|
+
};
|
|
418
|
+
}
|
|
419
|
+
|
|
420
|
+
// Usage in tests
|
|
421
|
+
describe('API Tests', () => {
|
|
422
|
+
let ctx: TestContext;
|
|
423
|
+
|
|
424
|
+
before(async () => {
|
|
425
|
+
ctx = await buildTestApp();
|
|
426
|
+
});
|
|
427
|
+
|
|
428
|
+
after(async () => {
|
|
429
|
+
await ctx.app.close();
|
|
430
|
+
});
|
|
431
|
+
|
|
432
|
+
it('should work', async (t) => {
|
|
433
|
+
const response = await ctx.inject({
|
|
434
|
+
method: 'GET',
|
|
435
|
+
url: '/health',
|
|
436
|
+
});
|
|
437
|
+
t.assert.equal(response.statusCode, 200);
|
|
438
|
+
});
|
|
439
|
+
});
|
|
440
|
+
```
|
|
441
|
+
|
|
442
|
+
## Database Testing with Transactions
|
|
443
|
+
|
|
444
|
+
Use transactions for test isolation:
|
|
445
|
+
|
|
446
|
+
```typescript
|
|
447
|
+
describe('Database Integration', () => {
|
|
448
|
+
let app;
|
|
449
|
+
let transaction;
|
|
450
|
+
|
|
451
|
+
before(async () => {
|
|
452
|
+
app = await buildApp();
|
|
453
|
+
await app.ready();
|
|
454
|
+
});
|
|
455
|
+
|
|
456
|
+
after(async () => {
|
|
457
|
+
await app.close();
|
|
458
|
+
});
|
|
459
|
+
|
|
460
|
+
beforeEach(async () => {
|
|
461
|
+
transaction = await app.db.beginTransaction();
|
|
462
|
+
app.db.setTransaction(transaction);
|
|
463
|
+
});
|
|
464
|
+
|
|
465
|
+
afterEach(async () => {
|
|
466
|
+
await transaction.rollback();
|
|
467
|
+
});
|
|
468
|
+
|
|
469
|
+
it('should create user', async (t) => {
|
|
470
|
+
const response = await app.inject({
|
|
471
|
+
method: 'POST',
|
|
472
|
+
url: '/users',
|
|
473
|
+
payload: { name: 'Test', email: 'test@example.com' },
|
|
474
|
+
});
|
|
475
|
+
|
|
476
|
+
t.assert.equal(response.statusCode, 201);
|
|
477
|
+
// Transaction is rolled back after test
|
|
478
|
+
});
|
|
479
|
+
});
|
|
480
|
+
```
|
|
481
|
+
|
|
482
|
+
## Parallel Test Execution
|
|
483
|
+
|
|
484
|
+
Structure tests for parallel execution:
|
|
485
|
+
|
|
486
|
+
```typescript
|
|
487
|
+
// Tests run in parallel by default with node:test
|
|
488
|
+
// Use separate app instances or proper isolation
|
|
489
|
+
|
|
490
|
+
import { describe, it } from 'node:test';
|
|
491
|
+
|
|
492
|
+
describe('User API', async () => {
|
|
493
|
+
// Each test suite gets its own app instance
|
|
494
|
+
const app = await buildTestApp();
|
|
495
|
+
|
|
496
|
+
it('test 1', async (t) => {
|
|
497
|
+
// ...
|
|
498
|
+
});
|
|
499
|
+
|
|
500
|
+
it('test 2', async (t) => {
|
|
501
|
+
// ...
|
|
502
|
+
});
|
|
503
|
+
|
|
504
|
+
// Cleanup after all tests in this suite
|
|
505
|
+
after(() => app.close());
|
|
506
|
+
});
|
|
507
|
+
|
|
508
|
+
describe('Post API', async () => {
|
|
509
|
+
const app = await buildTestApp();
|
|
510
|
+
|
|
511
|
+
it('test 1', async (t) => {
|
|
512
|
+
// ...
|
|
513
|
+
});
|
|
514
|
+
|
|
515
|
+
after(() => app.close());
|
|
516
|
+
});
|
|
517
|
+
```
|
|
518
|
+
|
|
519
|
+
## Running Tests
|
|
520
|
+
|
|
521
|
+
```bash
|
|
522
|
+
# Run all tests
|
|
523
|
+
node --test
|
|
524
|
+
|
|
525
|
+
# Run with TypeScript
|
|
526
|
+
node --test src/**/*.test.ts
|
|
527
|
+
|
|
528
|
+
# Run specific file
|
|
529
|
+
node --test src/routes/users.test.ts
|
|
530
|
+
|
|
531
|
+
# With coverage
|
|
532
|
+
node --test --experimental-test-coverage
|
|
533
|
+
|
|
534
|
+
# Watch mode
|
|
535
|
+
node --test --watch
|
|
536
|
+
```
|