@nextrush/errors 3.0.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.
- package/LICENSE +21 -0
- package/README.md +681 -0
- package/dist/index.d.ts +583 -0
- package/dist/index.js +1021 -0
- package/dist/index.js.map +1 -0
- package/package.json +59 -0
- package/src/__tests__/base.test.ts +180 -0
- package/src/__tests__/factory.test.ts +312 -0
- package/src/__tests__/http-errors.test.ts +458 -0
- package/src/__tests__/middleware.test.ts +385 -0
- package/src/__tests__/validation.test.ts +315 -0
- package/src/base.ts +162 -0
- package/src/factory.ts +205 -0
- package/src/http-errors.ts +409 -0
- package/src/index.ts +101 -0
- package/src/middleware.ts +183 -0
- package/src/validation.ts +210 -0
|
@@ -0,0 +1,385 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* @nextrush/errors - Middleware Tests
|
|
3
|
+
*/
|
|
4
|
+
|
|
5
|
+
import type { Context } from '@nextrush/types';
|
|
6
|
+
import { describe, expect, it, vi } from 'vitest';
|
|
7
|
+
import { BadRequestError, InternalServerError, NotFoundError } from '../http-errors';
|
|
8
|
+
import { catchAsync, errorHandler, notFoundHandler } from '../middleware';
|
|
9
|
+
|
|
10
|
+
function createMockContext(): Context {
|
|
11
|
+
const ctx = {
|
|
12
|
+
method: 'GET',
|
|
13
|
+
url: '/test',
|
|
14
|
+
path: '/test',
|
|
15
|
+
query: {},
|
|
16
|
+
headers: {},
|
|
17
|
+
ip: '127.0.0.1',
|
|
18
|
+
body: undefined,
|
|
19
|
+
params: {},
|
|
20
|
+
status: 200,
|
|
21
|
+
json: vi.fn(),
|
|
22
|
+
send: vi.fn(),
|
|
23
|
+
html: vi.fn(),
|
|
24
|
+
redirect: vi.fn(),
|
|
25
|
+
set: vi.fn(),
|
|
26
|
+
get: vi.fn(),
|
|
27
|
+
next: vi.fn().mockResolvedValue(undefined),
|
|
28
|
+
state: {},
|
|
29
|
+
responded: false,
|
|
30
|
+
raw: { req: {} as never, res: {} as never },
|
|
31
|
+
} as unknown as Context;
|
|
32
|
+
return ctx;
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
const noop = async (): Promise<void> => {};
|
|
36
|
+
|
|
37
|
+
describe('errorHandler', () => {
|
|
38
|
+
describe('basic functionality', () => {
|
|
39
|
+
it('should pass through when no error', async () => {
|
|
40
|
+
const handler = errorHandler();
|
|
41
|
+
const ctx = createMockContext();
|
|
42
|
+
|
|
43
|
+
await handler(ctx, async () => {
|
|
44
|
+
ctx.json({ ok: true });
|
|
45
|
+
});
|
|
46
|
+
|
|
47
|
+
expect(ctx.json).toHaveBeenCalledWith({ ok: true });
|
|
48
|
+
});
|
|
49
|
+
|
|
50
|
+
it('should catch and handle HttpError', async () => {
|
|
51
|
+
const handler = errorHandler();
|
|
52
|
+
const ctx = createMockContext();
|
|
53
|
+
|
|
54
|
+
await handler(ctx, async () => {
|
|
55
|
+
throw new NotFoundError('User not found');
|
|
56
|
+
});
|
|
57
|
+
|
|
58
|
+
expect(ctx.status).toBe(404);
|
|
59
|
+
expect(ctx.json).toHaveBeenCalledWith({
|
|
60
|
+
error: 'NotFoundError',
|
|
61
|
+
message: 'User not found',
|
|
62
|
+
code: 'NOT_FOUND',
|
|
63
|
+
status: 404,
|
|
64
|
+
});
|
|
65
|
+
});
|
|
66
|
+
|
|
67
|
+
it('should catch and handle regular Error', async () => {
|
|
68
|
+
const handler = errorHandler();
|
|
69
|
+
const ctx = createMockContext();
|
|
70
|
+
|
|
71
|
+
await handler(ctx, async () => {
|
|
72
|
+
throw new Error('Something went wrong');
|
|
73
|
+
});
|
|
74
|
+
|
|
75
|
+
expect(ctx.status).toBe(500);
|
|
76
|
+
expect(ctx.json).toHaveBeenCalledWith({
|
|
77
|
+
error: 'Internal Server Error',
|
|
78
|
+
message: 'Internal Server Error',
|
|
79
|
+
code: 'INTERNAL_ERROR',
|
|
80
|
+
status: 500,
|
|
81
|
+
});
|
|
82
|
+
});
|
|
83
|
+
|
|
84
|
+
it('should handle non-Error thrown values', async () => {
|
|
85
|
+
const handler = errorHandler();
|
|
86
|
+
const ctx = createMockContext();
|
|
87
|
+
|
|
88
|
+
await handler(ctx, async () => {
|
|
89
|
+
throw 'string error';
|
|
90
|
+
});
|
|
91
|
+
|
|
92
|
+
expect(ctx.status).toBe(500);
|
|
93
|
+
expect(ctx.json).toHaveBeenCalled();
|
|
94
|
+
});
|
|
95
|
+
});
|
|
96
|
+
|
|
97
|
+
describe('includeStack option', () => {
|
|
98
|
+
it('should include stack when enabled', async () => {
|
|
99
|
+
const handler = errorHandler({ includeStack: true });
|
|
100
|
+
const ctx = createMockContext();
|
|
101
|
+
|
|
102
|
+
await handler(ctx, async () => {
|
|
103
|
+
throw new BadRequestError('Invalid');
|
|
104
|
+
});
|
|
105
|
+
|
|
106
|
+
const jsonCall = (ctx.json as ReturnType<typeof vi.fn>).mock.calls[0][0];
|
|
107
|
+
expect(jsonCall.stack).toBeDefined();
|
|
108
|
+
expect(Array.isArray(jsonCall.stack)).toBe(true);
|
|
109
|
+
});
|
|
110
|
+
|
|
111
|
+
it('should not include stack by default', async () => {
|
|
112
|
+
const handler = errorHandler();
|
|
113
|
+
const ctx = createMockContext();
|
|
114
|
+
|
|
115
|
+
await handler(ctx, async () => {
|
|
116
|
+
throw new BadRequestError('Invalid');
|
|
117
|
+
});
|
|
118
|
+
|
|
119
|
+
const jsonCall = (ctx.json as ReturnType<typeof vi.fn>).mock.calls[0][0];
|
|
120
|
+
expect(jsonCall.stack).toBeUndefined();
|
|
121
|
+
});
|
|
122
|
+
});
|
|
123
|
+
|
|
124
|
+
describe('logger option', () => {
|
|
125
|
+
it('should call custom logger', async () => {
|
|
126
|
+
const logger = vi.fn();
|
|
127
|
+
const handler = errorHandler({ logger });
|
|
128
|
+
const ctx = createMockContext();
|
|
129
|
+
const error = new NotFoundError('Not found');
|
|
130
|
+
|
|
131
|
+
await handler(ctx, async () => {
|
|
132
|
+
throw error;
|
|
133
|
+
});
|
|
134
|
+
|
|
135
|
+
expect(logger).toHaveBeenCalledWith(error, ctx);
|
|
136
|
+
});
|
|
137
|
+
|
|
138
|
+
it('should use default logger', async () => {
|
|
139
|
+
const consoleSpy = vi.spyOn(console, 'warn').mockImplementation(() => {});
|
|
140
|
+
const handler = errorHandler();
|
|
141
|
+
const ctx = createMockContext();
|
|
142
|
+
|
|
143
|
+
await handler(ctx, async () => {
|
|
144
|
+
throw new NotFoundError('Not found');
|
|
145
|
+
});
|
|
146
|
+
|
|
147
|
+
expect(consoleSpy).toHaveBeenCalled();
|
|
148
|
+
consoleSpy.mockRestore();
|
|
149
|
+
});
|
|
150
|
+
|
|
151
|
+
it('should log 5xx errors as error level', async () => {
|
|
152
|
+
const consoleSpy = vi.spyOn(console, 'error').mockImplementation(() => {});
|
|
153
|
+
const handler = errorHandler();
|
|
154
|
+
const ctx = createMockContext();
|
|
155
|
+
|
|
156
|
+
await handler(ctx, async () => {
|
|
157
|
+
throw new InternalServerError('Server error');
|
|
158
|
+
});
|
|
159
|
+
|
|
160
|
+
expect(consoleSpy).toHaveBeenCalled();
|
|
161
|
+
consoleSpy.mockRestore();
|
|
162
|
+
});
|
|
163
|
+
});
|
|
164
|
+
|
|
165
|
+
describe('transform option', () => {
|
|
166
|
+
it('should use custom transform', async () => {
|
|
167
|
+
const transform = vi.fn().mockReturnValue({ customError: true });
|
|
168
|
+
const handler = errorHandler({ transform });
|
|
169
|
+
const ctx = createMockContext();
|
|
170
|
+
|
|
171
|
+
await handler(ctx, async () => {
|
|
172
|
+
throw new BadRequestError('Invalid');
|
|
173
|
+
});
|
|
174
|
+
|
|
175
|
+
expect(transform).toHaveBeenCalled();
|
|
176
|
+
expect(ctx.json).toHaveBeenCalledWith({ customError: true });
|
|
177
|
+
});
|
|
178
|
+
});
|
|
179
|
+
|
|
180
|
+
describe('handlers option', () => {
|
|
181
|
+
it('should use custom handler for specific error type', async () => {
|
|
182
|
+
const customHandler = vi.fn();
|
|
183
|
+
const handlers = new Map<
|
|
184
|
+
new (...args: unknown[]) => Error,
|
|
185
|
+
(error: Error, ctx: Context) => void
|
|
186
|
+
>([[NotFoundError as unknown as new (...args: unknown[]) => Error, customHandler]]);
|
|
187
|
+
const handler = errorHandler({ handlers });
|
|
188
|
+
const ctx = createMockContext();
|
|
189
|
+
const error = new NotFoundError('Not found');
|
|
190
|
+
|
|
191
|
+
await handler(ctx, async () => {
|
|
192
|
+
throw error;
|
|
193
|
+
});
|
|
194
|
+
|
|
195
|
+
expect(customHandler).toHaveBeenCalledWith(error, ctx);
|
|
196
|
+
expect(ctx.json).not.toHaveBeenCalled();
|
|
197
|
+
});
|
|
198
|
+
|
|
199
|
+
it('should fall back to default for unhandled types', async () => {
|
|
200
|
+
const customHandler = vi.fn();
|
|
201
|
+
const handlers = new Map<
|
|
202
|
+
new (...args: unknown[]) => Error,
|
|
203
|
+
(error: Error, ctx: Context) => void
|
|
204
|
+
>([[NotFoundError as unknown as new (...args: unknown[]) => Error, customHandler]]);
|
|
205
|
+
const handler = errorHandler({ handlers });
|
|
206
|
+
const ctx = createMockContext();
|
|
207
|
+
|
|
208
|
+
await handler(ctx, async () => {
|
|
209
|
+
throw new BadRequestError('Invalid');
|
|
210
|
+
});
|
|
211
|
+
|
|
212
|
+
expect(customHandler).not.toHaveBeenCalled();
|
|
213
|
+
expect(ctx.json).toHaveBeenCalled();
|
|
214
|
+
});
|
|
215
|
+
});
|
|
216
|
+
|
|
217
|
+
describe('error details', () => {
|
|
218
|
+
it('should include details for exposed errors', async () => {
|
|
219
|
+
const handler = errorHandler();
|
|
220
|
+
const ctx = createMockContext();
|
|
221
|
+
|
|
222
|
+
await handler(ctx, async () => {
|
|
223
|
+
throw new BadRequestError('Invalid', { details: { field: 'email' } });
|
|
224
|
+
});
|
|
225
|
+
|
|
226
|
+
const jsonCall = (ctx.json as ReturnType<typeof vi.fn>).mock.calls[0][0];
|
|
227
|
+
expect(jsonCall.details).toEqual({ field: 'email' });
|
|
228
|
+
});
|
|
229
|
+
|
|
230
|
+
it('should not include details for non-exposed errors', async () => {
|
|
231
|
+
const handler = errorHandler();
|
|
232
|
+
const ctx = createMockContext();
|
|
233
|
+
|
|
234
|
+
await handler(ctx, async () => {
|
|
235
|
+
throw new InternalServerError('Error');
|
|
236
|
+
});
|
|
237
|
+
|
|
238
|
+
const jsonCall = (ctx.json as ReturnType<typeof vi.fn>).mock.calls[0][0];
|
|
239
|
+
expect(jsonCall.details).toBeUndefined();
|
|
240
|
+
});
|
|
241
|
+
});
|
|
242
|
+
});
|
|
243
|
+
|
|
244
|
+
describe('notFoundHandler', () => {
|
|
245
|
+
it('should return 404 response', async () => {
|
|
246
|
+
const handler = notFoundHandler();
|
|
247
|
+
const ctx = createMockContext();
|
|
248
|
+
ctx.status = 404;
|
|
249
|
+
|
|
250
|
+
await handler(ctx, noop);
|
|
251
|
+
|
|
252
|
+
expect(ctx.status).toBe(404);
|
|
253
|
+
expect(ctx.json).toHaveBeenCalledWith({
|
|
254
|
+
error: 'NotFoundError',
|
|
255
|
+
message: 'Not Found',
|
|
256
|
+
code: 'NOT_FOUND',
|
|
257
|
+
status: 404,
|
|
258
|
+
});
|
|
259
|
+
});
|
|
260
|
+
|
|
261
|
+
it('should accept custom message', async () => {
|
|
262
|
+
const handler = notFoundHandler('Resource does not exist');
|
|
263
|
+
const ctx = createMockContext();
|
|
264
|
+
ctx.status = 404;
|
|
265
|
+
|
|
266
|
+
await handler(ctx, noop);
|
|
267
|
+
|
|
268
|
+
expect(ctx.json).toHaveBeenCalledWith(
|
|
269
|
+
expect.objectContaining({ message: 'Resource does not exist' })
|
|
270
|
+
);
|
|
271
|
+
});
|
|
272
|
+
|
|
273
|
+
it('should not handle 200 status (response was sent)', async () => {
|
|
274
|
+
const handler = notFoundHandler();
|
|
275
|
+
const ctx = createMockContext();
|
|
276
|
+
ctx.status = 200;
|
|
277
|
+
|
|
278
|
+
await handler(ctx, noop);
|
|
279
|
+
|
|
280
|
+
expect(ctx.status).toBe(200);
|
|
281
|
+
expect(ctx.json).not.toHaveBeenCalled();
|
|
282
|
+
});
|
|
283
|
+
|
|
284
|
+
it('should handle 404 status', async () => {
|
|
285
|
+
const handler = notFoundHandler();
|
|
286
|
+
const ctx = createMockContext();
|
|
287
|
+
ctx.status = 404;
|
|
288
|
+
|
|
289
|
+
await handler(ctx, noop);
|
|
290
|
+
|
|
291
|
+
expect(ctx.json).toHaveBeenCalled();
|
|
292
|
+
});
|
|
293
|
+
|
|
294
|
+
it('should not override non-404/200 status', async () => {
|
|
295
|
+
const handler = notFoundHandler();
|
|
296
|
+
const ctx = createMockContext();
|
|
297
|
+
ctx.status = 201;
|
|
298
|
+
|
|
299
|
+
await handler(ctx, noop);
|
|
300
|
+
|
|
301
|
+
expect(ctx.status).toBe(201);
|
|
302
|
+
expect(ctx.json).not.toHaveBeenCalled();
|
|
303
|
+
});
|
|
304
|
+
});
|
|
305
|
+
|
|
306
|
+
describe('catchAsync', () => {
|
|
307
|
+
it('should pass through successful handlers', async () => {
|
|
308
|
+
const innerHandler = vi.fn().mockResolvedValue(undefined);
|
|
309
|
+
const handler = catchAsync(innerHandler);
|
|
310
|
+
const ctx = createMockContext();
|
|
311
|
+
|
|
312
|
+
await handler(ctx, noop);
|
|
313
|
+
|
|
314
|
+
expect(innerHandler).toHaveBeenCalledWith(ctx, noop);
|
|
315
|
+
});
|
|
316
|
+
|
|
317
|
+
it('should re-throw errors', async () => {
|
|
318
|
+
const error = new NotFoundError('Not found');
|
|
319
|
+
const innerHandler = vi.fn().mockRejectedValue(error);
|
|
320
|
+
const handler = catchAsync(innerHandler);
|
|
321
|
+
const ctx = createMockContext();
|
|
322
|
+
|
|
323
|
+
await expect(handler(ctx, noop)).rejects.toThrow(error);
|
|
324
|
+
});
|
|
325
|
+
|
|
326
|
+
it('should pass next to inner handler', async () => {
|
|
327
|
+
const innerHandler = vi.fn().mockResolvedValue(undefined);
|
|
328
|
+
const handler = catchAsync(innerHandler);
|
|
329
|
+
const ctx = createMockContext();
|
|
330
|
+
const next = vi.fn();
|
|
331
|
+
|
|
332
|
+
await handler(ctx, next);
|
|
333
|
+
|
|
334
|
+
expect(innerHandler).toHaveBeenCalledWith(ctx, next);
|
|
335
|
+
});
|
|
336
|
+
});
|
|
337
|
+
|
|
338
|
+
describe('Integration scenarios', () => {
|
|
339
|
+
it('should work with errorHandler and notFoundHandler together', async () => {
|
|
340
|
+
const errHandler = errorHandler();
|
|
341
|
+
const notFoundHdlr = notFoundHandler();
|
|
342
|
+
const ctx = createMockContext();
|
|
343
|
+
ctx.status = 404;
|
|
344
|
+
|
|
345
|
+
await errHandler(ctx, async () => {
|
|
346
|
+
await notFoundHdlr(ctx, noop);
|
|
347
|
+
});
|
|
348
|
+
|
|
349
|
+
expect(ctx.status).toBe(404);
|
|
350
|
+
expect(ctx.json).toHaveBeenCalledWith(expect.objectContaining({ code: 'NOT_FOUND' }));
|
|
351
|
+
});
|
|
352
|
+
|
|
353
|
+
it('should handle thrown NotFoundError in route', async () => {
|
|
354
|
+
const errHandler = errorHandler();
|
|
355
|
+
const ctx = createMockContext();
|
|
356
|
+
|
|
357
|
+
await errHandler(ctx, async () => {
|
|
358
|
+
throw new NotFoundError('User with ID 123 not found');
|
|
359
|
+
});
|
|
360
|
+
|
|
361
|
+
expect(ctx.status).toBe(404);
|
|
362
|
+
expect(ctx.json).toHaveBeenCalledWith(
|
|
363
|
+
expect.objectContaining({ message: 'User with ID 123 not found' })
|
|
364
|
+
);
|
|
365
|
+
});
|
|
366
|
+
|
|
367
|
+
it('should handle validation errors with details', async () => {
|
|
368
|
+
const errHandler = errorHandler();
|
|
369
|
+
const ctx = createMockContext();
|
|
370
|
+
|
|
371
|
+
await errHandler(ctx, async () => {
|
|
372
|
+
throw new BadRequestError('Validation failed', {
|
|
373
|
+
details: {
|
|
374
|
+
issues: [
|
|
375
|
+
{ path: 'email', message: 'Invalid email' },
|
|
376
|
+
{ path: 'name', message: 'Name is required' },
|
|
377
|
+
],
|
|
378
|
+
},
|
|
379
|
+
});
|
|
380
|
+
});
|
|
381
|
+
|
|
382
|
+
const jsonCall = (ctx.json as ReturnType<typeof vi.fn>).mock.calls[0][0];
|
|
383
|
+
expect(jsonCall.details.issues).toHaveLength(2);
|
|
384
|
+
});
|
|
385
|
+
});
|
|
@@ -0,0 +1,315 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* @nextrush/errors - Validation Error Tests
|
|
3
|
+
*/
|
|
4
|
+
|
|
5
|
+
import { describe, expect, it } from 'vitest';
|
|
6
|
+
import { NextRushError } from '../base';
|
|
7
|
+
import {
|
|
8
|
+
InvalidEmailError,
|
|
9
|
+
InvalidUrlError,
|
|
10
|
+
LengthError,
|
|
11
|
+
PatternError,
|
|
12
|
+
RangeValidationError,
|
|
13
|
+
RequiredFieldError,
|
|
14
|
+
TypeMismatchError,
|
|
15
|
+
ValidationError,
|
|
16
|
+
} from '../validation';
|
|
17
|
+
|
|
18
|
+
describe('ValidationError', () => {
|
|
19
|
+
describe('constructor', () => {
|
|
20
|
+
it('should create error with issues', () => {
|
|
21
|
+
const issues = [
|
|
22
|
+
{ path: 'email', message: 'Invalid email' },
|
|
23
|
+
{ path: 'name', message: 'Name is required' },
|
|
24
|
+
];
|
|
25
|
+
const error = new ValidationError(issues);
|
|
26
|
+
|
|
27
|
+
expect(error.status).toBe(400);
|
|
28
|
+
expect(error.code).toBe('VALIDATION_ERROR');
|
|
29
|
+
expect(error.expose).toBe(true);
|
|
30
|
+
expect(error.issues).toEqual(issues);
|
|
31
|
+
expect(error.message).toBe('Validation failed');
|
|
32
|
+
});
|
|
33
|
+
|
|
34
|
+
it('should accept custom message', () => {
|
|
35
|
+
const error = new ValidationError(
|
|
36
|
+
[{ path: 'field', message: 'error' }],
|
|
37
|
+
'Form validation failed'
|
|
38
|
+
);
|
|
39
|
+
expect(error.message).toBe('Form validation failed');
|
|
40
|
+
});
|
|
41
|
+
|
|
42
|
+
it('should be instanceof NextRushError', () => {
|
|
43
|
+
const error = new ValidationError([{ path: 'field', message: 'error' }]);
|
|
44
|
+
expect(error).toBeInstanceOf(NextRushError);
|
|
45
|
+
expect(error).toBeInstanceOf(Error);
|
|
46
|
+
});
|
|
47
|
+
|
|
48
|
+
it('should store issues as canonical source without duplicating in details', () => {
|
|
49
|
+
const issues = [{ path: 'email', message: 'Invalid' }];
|
|
50
|
+
const error = new ValidationError(issues);
|
|
51
|
+
expect(error.issues).toEqual(issues);
|
|
52
|
+
expect(error.details).toBeUndefined();
|
|
53
|
+
});
|
|
54
|
+
});
|
|
55
|
+
|
|
56
|
+
describe('fromField', () => {
|
|
57
|
+
it('should create error from single field', () => {
|
|
58
|
+
const error = ValidationError.fromField('email', 'Invalid email format', 'email');
|
|
59
|
+
|
|
60
|
+
expect(error.issues).toHaveLength(1);
|
|
61
|
+
expect(error.issues[0]).toEqual({
|
|
62
|
+
path: 'email',
|
|
63
|
+
message: 'Invalid email format',
|
|
64
|
+
rule: 'email',
|
|
65
|
+
});
|
|
66
|
+
});
|
|
67
|
+
|
|
68
|
+
it('should work without rule', () => {
|
|
69
|
+
const error = ValidationError.fromField('name', 'Name is required');
|
|
70
|
+
expect(error.issues[0].rule).toBeUndefined();
|
|
71
|
+
});
|
|
72
|
+
});
|
|
73
|
+
|
|
74
|
+
describe('fromFields', () => {
|
|
75
|
+
it('should create error from multiple fields', () => {
|
|
76
|
+
const error = ValidationError.fromFields({
|
|
77
|
+
email: 'Invalid email',
|
|
78
|
+
name: 'Name is required',
|
|
79
|
+
age: 'Must be at least 18',
|
|
80
|
+
});
|
|
81
|
+
|
|
82
|
+
expect(error.issues).toHaveLength(3);
|
|
83
|
+
expect(error.issues[0].path).toBe('email');
|
|
84
|
+
expect(error.issues[1].path).toBe('name');
|
|
85
|
+
expect(error.issues[2].path).toBe('age');
|
|
86
|
+
});
|
|
87
|
+
});
|
|
88
|
+
|
|
89
|
+
describe('hasErrorFor', () => {
|
|
90
|
+
it('should return true if field has errors', () => {
|
|
91
|
+
const error = new ValidationError([
|
|
92
|
+
{ path: 'email', message: 'Invalid' },
|
|
93
|
+
{ path: 'name', message: 'Required' },
|
|
94
|
+
]);
|
|
95
|
+
|
|
96
|
+
expect(error.hasErrorFor('email')).toBe(true);
|
|
97
|
+
expect(error.hasErrorFor('name')).toBe(true);
|
|
98
|
+
expect(error.hasErrorFor('age')).toBe(false);
|
|
99
|
+
});
|
|
100
|
+
});
|
|
101
|
+
|
|
102
|
+
describe('getErrorsFor', () => {
|
|
103
|
+
it('should return errors for specific field', () => {
|
|
104
|
+
const error = new ValidationError([
|
|
105
|
+
{ path: 'email', message: 'Invalid format' },
|
|
106
|
+
{ path: 'email', message: 'Already exists' },
|
|
107
|
+
{ path: 'name', message: 'Required' },
|
|
108
|
+
]);
|
|
109
|
+
|
|
110
|
+
const emailErrors = error.getErrorsFor('email');
|
|
111
|
+
expect(emailErrors).toHaveLength(2);
|
|
112
|
+
expect(emailErrors[0].message).toBe('Invalid format');
|
|
113
|
+
expect(emailErrors[1].message).toBe('Already exists');
|
|
114
|
+
});
|
|
115
|
+
|
|
116
|
+
it('should return empty array if no errors', () => {
|
|
117
|
+
const error = new ValidationError([{ path: 'email', message: 'Invalid' }]);
|
|
118
|
+
expect(error.getErrorsFor('name')).toEqual([]);
|
|
119
|
+
});
|
|
120
|
+
});
|
|
121
|
+
|
|
122
|
+
describe('getFirstError', () => {
|
|
123
|
+
it('should return first error message for field', () => {
|
|
124
|
+
const error = new ValidationError([
|
|
125
|
+
{ path: 'email', message: 'First error' },
|
|
126
|
+
{ path: 'email', message: 'Second error' },
|
|
127
|
+
]);
|
|
128
|
+
|
|
129
|
+
expect(error.getFirstError('email')).toBe('First error');
|
|
130
|
+
});
|
|
131
|
+
|
|
132
|
+
it('should return undefined if no errors', () => {
|
|
133
|
+
const error = new ValidationError([{ path: 'email', message: 'Invalid' }]);
|
|
134
|
+
expect(error.getFirstError('name')).toBeUndefined();
|
|
135
|
+
});
|
|
136
|
+
});
|
|
137
|
+
|
|
138
|
+
describe('toFlatObject', () => {
|
|
139
|
+
it('should convert to flat object', () => {
|
|
140
|
+
const error = new ValidationError([
|
|
141
|
+
{ path: 'email', message: 'Invalid' },
|
|
142
|
+
{ path: 'name', message: 'Required' },
|
|
143
|
+
]);
|
|
144
|
+
|
|
145
|
+
expect(error.toFlatObject()).toEqual({
|
|
146
|
+
email: 'Invalid',
|
|
147
|
+
name: 'Required',
|
|
148
|
+
});
|
|
149
|
+
});
|
|
150
|
+
|
|
151
|
+
it('should use first error for duplicate fields', () => {
|
|
152
|
+
const error = new ValidationError([
|
|
153
|
+
{ path: 'email', message: 'First' },
|
|
154
|
+
{ path: 'email', message: 'Second' },
|
|
155
|
+
]);
|
|
156
|
+
|
|
157
|
+
expect(error.toFlatObject()).toEqual({ email: 'First' });
|
|
158
|
+
});
|
|
159
|
+
});
|
|
160
|
+
|
|
161
|
+
describe('toJSON', () => {
|
|
162
|
+
it('should include issues in JSON', () => {
|
|
163
|
+
const issues = [{ path: 'email', message: 'Invalid' }];
|
|
164
|
+
const error = new ValidationError(issues);
|
|
165
|
+
const json = error.toJSON();
|
|
166
|
+
|
|
167
|
+
expect(json).toEqual({
|
|
168
|
+
error: 'ValidationError',
|
|
169
|
+
message: 'Validation failed',
|
|
170
|
+
code: 'VALIDATION_ERROR',
|
|
171
|
+
status: 400,
|
|
172
|
+
issues,
|
|
173
|
+
});
|
|
174
|
+
});
|
|
175
|
+
});
|
|
176
|
+
});
|
|
177
|
+
|
|
178
|
+
describe('RequiredFieldError', () => {
|
|
179
|
+
it('should create required field error', () => {
|
|
180
|
+
const error = new RequiredFieldError('name');
|
|
181
|
+
|
|
182
|
+
expect(error.status).toBe(400);
|
|
183
|
+
expect(error.message).toBe('name is required');
|
|
184
|
+
expect(error.issues).toHaveLength(1);
|
|
185
|
+
expect(error.issues[0]).toEqual({
|
|
186
|
+
path: 'name',
|
|
187
|
+
message: 'name is required',
|
|
188
|
+
rule: 'required',
|
|
189
|
+
});
|
|
190
|
+
});
|
|
191
|
+
});
|
|
192
|
+
|
|
193
|
+
describe('TypeMismatchError', () => {
|
|
194
|
+
it('should create type mismatch error', () => {
|
|
195
|
+
const error = new TypeMismatchError('age', 'number', 'string');
|
|
196
|
+
|
|
197
|
+
expect(error.status).toBe(400);
|
|
198
|
+
expect(error.message).toBe('age must be of type number');
|
|
199
|
+
expect(error.issues[0]).toEqual({
|
|
200
|
+
path: 'age',
|
|
201
|
+
message: 'Expected number, received string',
|
|
202
|
+
rule: 'type',
|
|
203
|
+
expected: 'number',
|
|
204
|
+
received: 'string',
|
|
205
|
+
});
|
|
206
|
+
});
|
|
207
|
+
});
|
|
208
|
+
|
|
209
|
+
describe('RangeValidationError', () => {
|
|
210
|
+
it('should create range error with min', () => {
|
|
211
|
+
const error = new RangeValidationError('age', 18);
|
|
212
|
+
expect(error.message).toBe('age must be at least 18');
|
|
213
|
+
});
|
|
214
|
+
|
|
215
|
+
it('should create range error with max', () => {
|
|
216
|
+
const error = new RangeValidationError('quantity', undefined, 100);
|
|
217
|
+
expect(error.message).toBe('quantity must be at most 100');
|
|
218
|
+
});
|
|
219
|
+
|
|
220
|
+
it('should create range error with min and max', () => {
|
|
221
|
+
const error = new RangeValidationError('rating', 1, 5);
|
|
222
|
+
expect(error.message).toBe('rating must be at least 1 and at most 5');
|
|
223
|
+
expect(error.issues[0].expected).toEqual({ min: 1, max: 5 });
|
|
224
|
+
});
|
|
225
|
+
});
|
|
226
|
+
|
|
227
|
+
describe('LengthError', () => {
|
|
228
|
+
it('should create length error with min', () => {
|
|
229
|
+
const error = new LengthError('password', 8);
|
|
230
|
+
expect(error.message).toBe('password must be at least 8 characters');
|
|
231
|
+
});
|
|
232
|
+
|
|
233
|
+
it('should create length error with max', () => {
|
|
234
|
+
const error = new LengthError('username', undefined, 20);
|
|
235
|
+
expect(error.message).toBe('username must be at most 20 characters');
|
|
236
|
+
});
|
|
237
|
+
|
|
238
|
+
it('should create length error with min and max', () => {
|
|
239
|
+
const error = new LengthError('bio', 10, 500);
|
|
240
|
+
expect(error.message).toBe('bio must be at least 10 characters and at most 500 characters');
|
|
241
|
+
});
|
|
242
|
+
});
|
|
243
|
+
|
|
244
|
+
describe('PatternError', () => {
|
|
245
|
+
it('should create pattern error with default message', () => {
|
|
246
|
+
const error = new PatternError('phone', '^\\d{10}$');
|
|
247
|
+
|
|
248
|
+
expect(error.message).toBe('phone does not match required pattern');
|
|
249
|
+
expect(error.issues[0].expected).toBe('^\\d{10}$');
|
|
250
|
+
expect(error.issues[0].rule).toBe('pattern');
|
|
251
|
+
});
|
|
252
|
+
|
|
253
|
+
it('should create pattern error with custom message', () => {
|
|
254
|
+
const error = new PatternError('phone', '^\\d{10}$', 'Phone must be 10 digits');
|
|
255
|
+
expect(error.message).toBe('Phone must be 10 digits');
|
|
256
|
+
});
|
|
257
|
+
});
|
|
258
|
+
|
|
259
|
+
describe('InvalidEmailError', () => {
|
|
260
|
+
it('should create email error with default field', () => {
|
|
261
|
+
const error = new InvalidEmailError();
|
|
262
|
+
|
|
263
|
+
expect(error.message).toBe('Invalid email address');
|
|
264
|
+
expect(error.issues[0].path).toBe('email');
|
|
265
|
+
expect(error.issues[0].rule).toBe('email');
|
|
266
|
+
});
|
|
267
|
+
|
|
268
|
+
it('should create email error with custom field', () => {
|
|
269
|
+
const error = new InvalidEmailError('contactEmail');
|
|
270
|
+
expect(error.issues[0].path).toBe('contactEmail');
|
|
271
|
+
});
|
|
272
|
+
});
|
|
273
|
+
|
|
274
|
+
describe('InvalidUrlError', () => {
|
|
275
|
+
it('should create URL error with default field', () => {
|
|
276
|
+
const error = new InvalidUrlError();
|
|
277
|
+
|
|
278
|
+
expect(error.message).toBe('Invalid URL');
|
|
279
|
+
expect(error.issues[0].path).toBe('url');
|
|
280
|
+
expect(error.issues[0].rule).toBe('url');
|
|
281
|
+
});
|
|
282
|
+
|
|
283
|
+
it('should create URL error with custom field', () => {
|
|
284
|
+
const error = new InvalidUrlError('website');
|
|
285
|
+
expect(error.issues[0].path).toBe('website');
|
|
286
|
+
});
|
|
287
|
+
});
|
|
288
|
+
|
|
289
|
+
describe('Complex validation scenarios', () => {
|
|
290
|
+
it('should handle nested field paths', () => {
|
|
291
|
+
const error = new ValidationError([
|
|
292
|
+
{ path: 'user.address.city', message: 'City is required' },
|
|
293
|
+
{ path: 'user.address.zipCode', message: 'Invalid zip code' },
|
|
294
|
+
{ path: 'items[0].quantity', message: 'Quantity must be positive' },
|
|
295
|
+
]);
|
|
296
|
+
|
|
297
|
+
expect(error.hasErrorFor('user.address.city')).toBe(true);
|
|
298
|
+
expect(error.hasErrorFor('items[0].quantity')).toBe(true);
|
|
299
|
+
});
|
|
300
|
+
|
|
301
|
+
it('should handle issues with all properties', () => {
|
|
302
|
+
const error = new ValidationError([
|
|
303
|
+
{
|
|
304
|
+
path: 'age',
|
|
305
|
+
message: 'Age must be between 18 and 120',
|
|
306
|
+
rule: 'range',
|
|
307
|
+
expected: { min: 18, max: 120 },
|
|
308
|
+
received: 150,
|
|
309
|
+
},
|
|
310
|
+
]);
|
|
311
|
+
|
|
312
|
+
expect(error.issues[0].expected).toEqual({ min: 18, max: 120 });
|
|
313
|
+
expect(error.issues[0].received).toBe(150);
|
|
314
|
+
});
|
|
315
|
+
});
|