@nestia/e2e 9.0.0-dev.20251107-2 → 9.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 -21
- package/README.md +93 -93
- package/package.json +1 -1
- package/src/ArrayUtil.ts +320 -320
- package/src/DynamicExecutor.ts +274 -274
- package/src/GaffComparator.ts +287 -287
- package/src/MapUtil.ts +86 -86
- package/src/RandomGenerator.ts +490 -490
- package/src/TestValidator.ts +635 -635
- package/src/index.ts +4 -4
- package/src/internal/json_equal_to.ts +35 -35
- package/src/module.ts +7 -7
package/src/TestValidator.ts
CHANGED
|
@@ -1,635 +1,635 @@
|
|
|
1
|
-
import { RandomGenerator } from "./RandomGenerator";
|
|
2
|
-
import { json_equal_to } from "./internal/json_equal_to";
|
|
3
|
-
|
|
4
|
-
/**
|
|
5
|
-
* A comprehensive collection of E2E validation utilities for testing
|
|
6
|
-
* applications.
|
|
7
|
-
*
|
|
8
|
-
* TestValidator provides type-safe validation functions for common testing
|
|
9
|
-
* scenarios including condition checking, equality validation, error testing,
|
|
10
|
-
* HTTP error validation, pagination testing, search functionality validation,
|
|
11
|
-
* and sorting validation.
|
|
12
|
-
*
|
|
13
|
-
* Most functions use direct parameter passing for simplicity, while some
|
|
14
|
-
* maintain currying patterns for advanced composition. All provide detailed
|
|
15
|
-
* error messages for debugging failed assertions.
|
|
16
|
-
*
|
|
17
|
-
* @author Jeongho Nam - https://github.com/samchon
|
|
18
|
-
* @example
|
|
19
|
-
* ```typescript
|
|
20
|
-
* // Basic condition testing
|
|
21
|
-
* TestValidator.predicate("user should be authenticated", user.isAuthenticated);
|
|
22
|
-
*
|
|
23
|
-
* // Equality validation
|
|
24
|
-
* TestValidator.equals("API response should match expected", x, y);
|
|
25
|
-
*
|
|
26
|
-
* // Error validation
|
|
27
|
-
* TestValidator.error("should throw on invalid input", () => assertInput(""));
|
|
28
|
-
* ```;
|
|
29
|
-
*/
|
|
30
|
-
export namespace TestValidator {
|
|
31
|
-
/**
|
|
32
|
-
* Validates that a given condition evaluates to true.
|
|
33
|
-
*
|
|
34
|
-
* Supports synchronous boolean values, synchronous functions returning
|
|
35
|
-
* boolean, and asynchronous functions returning Promise<boolean>. The return
|
|
36
|
-
* type is automatically inferred based on the input type.
|
|
37
|
-
*
|
|
38
|
-
* @example
|
|
39
|
-
* ```typescript
|
|
40
|
-
* // Synchronous boolean
|
|
41
|
-
* TestValidator.predicate("user should exist", user !== null);
|
|
42
|
-
*
|
|
43
|
-
* // Synchronous function
|
|
44
|
-
* TestValidator.predicate("array should be empty", () => arr.length === 0);
|
|
45
|
-
*
|
|
46
|
-
* // Asynchronous function
|
|
47
|
-
* await TestValidator.predicate("database should be connected",
|
|
48
|
-
* async () => await db.ping()
|
|
49
|
-
* );
|
|
50
|
-
* ```;
|
|
51
|
-
*
|
|
52
|
-
* @param title - Descriptive title used in error messages when validation
|
|
53
|
-
* fails
|
|
54
|
-
* @param condition - The condition to validate (boolean, function, or async
|
|
55
|
-
* function)
|
|
56
|
-
* @returns Void or Promise<void> based on the input type
|
|
57
|
-
* @throws Error with descriptive message when condition is not satisfied
|
|
58
|
-
*/
|
|
59
|
-
export function predicate<
|
|
60
|
-
T extends boolean | (() => boolean) | (() => Promise<boolean>),
|
|
61
|
-
>(
|
|
62
|
-
title: string,
|
|
63
|
-
condition: T,
|
|
64
|
-
): T extends () => Promise<boolean> ? Promise<void> : void {
|
|
65
|
-
const message = () =>
|
|
66
|
-
`Bug on ${title}: expected condition is not satisfied.`;
|
|
67
|
-
|
|
68
|
-
// SCALAR
|
|
69
|
-
if (typeof condition === "boolean") {
|
|
70
|
-
if (condition !== true) throw new Error(message());
|
|
71
|
-
return undefined as any;
|
|
72
|
-
}
|
|
73
|
-
|
|
74
|
-
// CLOSURE
|
|
75
|
-
const output: boolean | Promise<boolean> = condition();
|
|
76
|
-
if (typeof output === "boolean") {
|
|
77
|
-
if (output !== true) throw new Error(message());
|
|
78
|
-
return undefined as any;
|
|
79
|
-
}
|
|
80
|
-
|
|
81
|
-
// ASYNCHRONOUS
|
|
82
|
-
return new Promise<void>((resolve, reject) => {
|
|
83
|
-
output
|
|
84
|
-
.then((flag) => {
|
|
85
|
-
if (flag === true) resolve();
|
|
86
|
-
else reject(message());
|
|
87
|
-
})
|
|
88
|
-
.catch(reject);
|
|
89
|
-
}) as any;
|
|
90
|
-
}
|
|
91
|
-
|
|
92
|
-
/**
|
|
93
|
-
* Validates deep equality between two values using JSON comparison.
|
|
94
|
-
*
|
|
95
|
-
* Performs recursive comparison of objects and arrays. Supports an optional
|
|
96
|
-
* exception filter to ignore specific keys during comparison. Useful for
|
|
97
|
-
* validating API responses, data transformations, and object state changes.
|
|
98
|
-
*
|
|
99
|
-
* @example
|
|
100
|
-
* ```typescript
|
|
101
|
-
* // Basic equality
|
|
102
|
-
* TestValidator.equals("response should match expected", expectedUser, actualUser);
|
|
103
|
-
*
|
|
104
|
-
* // Ignore timestamps in comparison
|
|
105
|
-
* TestValidator.equals("user data should match", expectedUser, actualUser,
|
|
106
|
-
* (key) => key === "updatedAt"
|
|
107
|
-
* );
|
|
108
|
-
*
|
|
109
|
-
* // Validate API response structure
|
|
110
|
-
* TestValidator.equals("API response structure",
|
|
111
|
-
* { id: 1, name: "John" },
|
|
112
|
-
* { id: 1, name: "John" }
|
|
113
|
-
* );
|
|
114
|
-
*
|
|
115
|
-
* // Type-safe nullable comparisons
|
|
116
|
-
* const nullableData: { name: string } | null = getData();
|
|
117
|
-
* TestValidator.equals("nullable check", nullableData, null);
|
|
118
|
-
* ```;
|
|
119
|
-
*
|
|
120
|
-
* @param title - Descriptive title used in error messages when values differ
|
|
121
|
-
* @param X - The first value to compare
|
|
122
|
-
* @param y - The second value to compare (can be null or undefined)
|
|
123
|
-
* @param exception - Optional filter function to exclude specific keys from
|
|
124
|
-
* comparison
|
|
125
|
-
* @throws Error with detailed diff information when values are not equal
|
|
126
|
-
*/
|
|
127
|
-
export function equals<X, Y extends X = X>(
|
|
128
|
-
title: string,
|
|
129
|
-
X: X,
|
|
130
|
-
y: Y | null | undefined,
|
|
131
|
-
exception?: (key: string) => boolean,
|
|
132
|
-
): void {
|
|
133
|
-
const diff: string[] = json_equal_to(exception ?? (() => false))(X)(y);
|
|
134
|
-
if (diff.length)
|
|
135
|
-
throw new Error(
|
|
136
|
-
[
|
|
137
|
-
`Bug on ${title}: found different values - [${diff.join(", ")}]:`,
|
|
138
|
-
"\n",
|
|
139
|
-
JSON.stringify({ x: X, y }, null, 2),
|
|
140
|
-
].join("\n"),
|
|
141
|
-
);
|
|
142
|
-
}
|
|
143
|
-
|
|
144
|
-
/**
|
|
145
|
-
* Validates deep inequality between two values using JSON comparison.
|
|
146
|
-
*
|
|
147
|
-
* Performs recursive comparison of objects and arrays to ensure they are NOT
|
|
148
|
-
* equal. Supports an optional exception filter to ignore specific keys during
|
|
149
|
-
* comparison. Useful for validating that data has changed, objects are
|
|
150
|
-
* different, or mutations have occurred.
|
|
151
|
-
*
|
|
152
|
-
* @example
|
|
153
|
-
* ```typescript
|
|
154
|
-
* // Basic inequality
|
|
155
|
-
* TestValidator.notEquals("user should be different after update", originalUser, updatedUser);
|
|
156
|
-
*
|
|
157
|
-
* // Ignore timestamps in comparison
|
|
158
|
-
* TestValidator.notEquals("user data should differ", originalUser, modifiedUser,
|
|
159
|
-
* (key) => key === "updatedAt"
|
|
160
|
-
* );
|
|
161
|
-
*
|
|
162
|
-
* // Validate state changes
|
|
163
|
-
* TestValidator.notEquals("state should have changed", initialState, currentState);
|
|
164
|
-
*
|
|
165
|
-
* // Type-safe nullable comparisons
|
|
166
|
-
* const mutableData: { count: number } | null = getMutableData();
|
|
167
|
-
* TestValidator.notEquals("should have changed", mutableData, null);
|
|
168
|
-
* ```;
|
|
169
|
-
*
|
|
170
|
-
* @param title - Descriptive title used in error messages when values are
|
|
171
|
-
* equal
|
|
172
|
-
* @param x - The first value to compare
|
|
173
|
-
* @param y - The second value to compare (can be null or undefined)
|
|
174
|
-
* @param exception - Optional filter function to exclude specific keys from
|
|
175
|
-
* comparison
|
|
176
|
-
* @throws Error when values are equal (indicating validation failure)
|
|
177
|
-
*/
|
|
178
|
-
export function notEquals<X, Y extends X = X>(
|
|
179
|
-
title: string,
|
|
180
|
-
x: X,
|
|
181
|
-
y: Y | null | undefined,
|
|
182
|
-
exception?: (key: string) => boolean,
|
|
183
|
-
): void {
|
|
184
|
-
const diff: string[] = json_equal_to(exception ?? (() => false))(x)(y);
|
|
185
|
-
if (diff.length === 0)
|
|
186
|
-
throw new Error(
|
|
187
|
-
[
|
|
188
|
-
`Bug on ${title}: values should be different but are equal:`,
|
|
189
|
-
"\n",
|
|
190
|
-
JSON.stringify({ x, y }, null, 2),
|
|
191
|
-
].join("\n"),
|
|
192
|
-
);
|
|
193
|
-
}
|
|
194
|
-
|
|
195
|
-
/**
|
|
196
|
-
* Validates that a function throws an error or rejects when executed.
|
|
197
|
-
*
|
|
198
|
-
* Expects the provided function to fail. If the function executes
|
|
199
|
-
* successfully without throwing an error or rejecting, this validator will
|
|
200
|
-
* throw an exception. Supports both synchronous and asynchronous functions.
|
|
201
|
-
*
|
|
202
|
-
* @example
|
|
203
|
-
* ```typescript
|
|
204
|
-
* // Synchronous error validation
|
|
205
|
-
* TestValidator.error("should reject invalid email",
|
|
206
|
-
* () => validateEmail("invalid-email")
|
|
207
|
-
* );
|
|
208
|
-
*
|
|
209
|
-
* // Asynchronous error validation
|
|
210
|
-
* await TestValidator.error("should reject unauthorized access",
|
|
211
|
-
* async () => await api.functional.getSecretData()
|
|
212
|
-
* );
|
|
213
|
-
*
|
|
214
|
-
* // Validate input validation
|
|
215
|
-
* TestValidator.error("should throw on empty string",
|
|
216
|
-
* () => processRequiredInput("")
|
|
217
|
-
* );
|
|
218
|
-
* ```;
|
|
219
|
-
*
|
|
220
|
-
* @param title - Descriptive title used in error messages when no error
|
|
221
|
-
* occurs
|
|
222
|
-
* @param task - The function that should throw an error or reject
|
|
223
|
-
* @returns Void or Promise<void> based on the input type
|
|
224
|
-
* @throws Error when the task function does not throw an error or reject
|
|
225
|
-
*/
|
|
226
|
-
export function error<T>(
|
|
227
|
-
title: string,
|
|
228
|
-
task: () => T,
|
|
229
|
-
): T extends Promise<any> ? Promise<void> : void {
|
|
230
|
-
const message = () => `Bug on ${title}: exception must be thrown.`;
|
|
231
|
-
try {
|
|
232
|
-
const output: T = task();
|
|
233
|
-
if (is_promise(output))
|
|
234
|
-
return new Promise<void>((resolve, reject) =>
|
|
235
|
-
output.catch(() => resolve()).then(() => reject(message())),
|
|
236
|
-
) as any;
|
|
237
|
-
else throw new Error(message());
|
|
238
|
-
} catch {
|
|
239
|
-
return undefined as any;
|
|
240
|
-
}
|
|
241
|
-
}
|
|
242
|
-
|
|
243
|
-
/**
|
|
244
|
-
* Validates that a function throws an HTTP error with specific status codes.
|
|
245
|
-
*
|
|
246
|
-
* Specialized error validator for HTTP operations. Validates that the
|
|
247
|
-
* function throws an HttpError with one of the specified status codes. Useful
|
|
248
|
-
* for testing API endpoints, authentication, and authorization logic.
|
|
249
|
-
*
|
|
250
|
-
* @example
|
|
251
|
-
* ```typescript
|
|
252
|
-
* // Validate 401 Unauthorized
|
|
253
|
-
* await TestValidator.httpError("should return 401 for invalid token", 401,
|
|
254
|
-
* async () => await api.functional.getProtectedResource("invalid-token")
|
|
255
|
-
* );
|
|
256
|
-
*
|
|
257
|
-
* // Validate multiple possible error codes
|
|
258
|
-
* await TestValidator.httpError("should return client error", [400, 404, 422],
|
|
259
|
-
* async () => await api.functional.updateNonexistentResource(data)
|
|
260
|
-
* );
|
|
261
|
-
*
|
|
262
|
-
* // Validate server errors
|
|
263
|
-
* TestValidator.httpError("should handle server errors", [500, 502, 503],
|
|
264
|
-
* () => callFaultyEndpoint()
|
|
265
|
-
* );
|
|
266
|
-
* ```;
|
|
267
|
-
*
|
|
268
|
-
* @param title - Descriptive title used in error messages
|
|
269
|
-
* @param status - Expected status code(s), can be a single number or array
|
|
270
|
-
* @param task - The function that should throw an HttpError
|
|
271
|
-
* @returns Void or Promise<void> based on the input type
|
|
272
|
-
* @throws Error when function doesn't throw HttpError or status code doesn't
|
|
273
|
-
* match
|
|
274
|
-
*/
|
|
275
|
-
export function httpError<T>(
|
|
276
|
-
title: string,
|
|
277
|
-
status: number | number[],
|
|
278
|
-
task: () => T,
|
|
279
|
-
): T extends Promise<any> ? Promise<void> : void {
|
|
280
|
-
if (typeof status === "number") status = [status];
|
|
281
|
-
const message = (actual?: number) =>
|
|
282
|
-
typeof actual === "number"
|
|
283
|
-
? `Bug on ${title}: status code must be ${status.join(
|
|
284
|
-
" or ",
|
|
285
|
-
)}, but ${actual}.`
|
|
286
|
-
: `Bug on ${title}: status code must be ${status.join(
|
|
287
|
-
" or ",
|
|
288
|
-
)}, but succeeded.`;
|
|
289
|
-
const predicate = (exp: any): Error | null =>
|
|
290
|
-
typeof exp === "object" &&
|
|
291
|
-
exp.constructor.name === "HttpError" &&
|
|
292
|
-
status.some((val) => val === exp.status)
|
|
293
|
-
? null
|
|
294
|
-
: new Error(
|
|
295
|
-
message(
|
|
296
|
-
typeof exp === "object" && exp.constructor.name === "HttpError"
|
|
297
|
-
? exp.status
|
|
298
|
-
: undefined,
|
|
299
|
-
),
|
|
300
|
-
);
|
|
301
|
-
try {
|
|
302
|
-
const output: T = task();
|
|
303
|
-
if (is_promise(output))
|
|
304
|
-
return new Promise<void>((resolve, reject) =>
|
|
305
|
-
output
|
|
306
|
-
.catch((exp) => {
|
|
307
|
-
const res: Error | null = predicate(exp);
|
|
308
|
-
if (res) reject(res);
|
|
309
|
-
else resolve();
|
|
310
|
-
})
|
|
311
|
-
.then(() => reject(new Error(message()))),
|
|
312
|
-
) as any;
|
|
313
|
-
else throw new Error(message());
|
|
314
|
-
} catch (exp) {
|
|
315
|
-
const res: Error | null = predicate(exp);
|
|
316
|
-
if (res) throw res;
|
|
317
|
-
return undefined!;
|
|
318
|
-
}
|
|
319
|
-
}
|
|
320
|
-
|
|
321
|
-
/**
|
|
322
|
-
* Validates pagination index API results against expected entity order.
|
|
323
|
-
*
|
|
324
|
-
* Compares the order of entities returned by a pagination API with manually
|
|
325
|
-
* sorted expected results. Validates that entity IDs appear in the correct
|
|
326
|
-
* sequence. Commonly used for testing database queries, search results, and
|
|
327
|
-
* any paginated data APIs.
|
|
328
|
-
*
|
|
329
|
-
* @example
|
|
330
|
-
* ```typescript
|
|
331
|
-
* // Test article pagination
|
|
332
|
-
* const expectedArticles = await db.articles.findAll({ order: 'created_at DESC' });
|
|
333
|
-
* const actualArticles = await api.functional.getArticles({ page: 1, limit: 10 });
|
|
334
|
-
*
|
|
335
|
-
* TestValidator.index("article pagination order", expectedArticles, actualArticles,
|
|
336
|
-
* true // enable trace logging
|
|
337
|
-
* );
|
|
338
|
-
*
|
|
339
|
-
* // Test user search results
|
|
340
|
-
* const manuallyFilteredUsers = allUsers.filter(u => u.name.includes("John"));
|
|
341
|
-
* const apiSearchResults = await api.functional.searchUsers({ query: "John" });
|
|
342
|
-
*
|
|
343
|
-
* TestValidator.index("user search results", manuallyFilteredUsers, apiSearchResults);
|
|
344
|
-
* ```;
|
|
345
|
-
*
|
|
346
|
-
* @param title - Descriptive title used in error messages when order differs
|
|
347
|
-
* @param expected - The expected entities in correct order
|
|
348
|
-
* @param gotten - The actual entities returned by the API
|
|
349
|
-
* @param trace - Optional flag to enable debug logging (default: false)
|
|
350
|
-
* @throws Error when entity order differs between expected and actual results
|
|
351
|
-
*/
|
|
352
|
-
export const index = <X extends IEntity<any>, Y extends X = X>(
|
|
353
|
-
title: string,
|
|
354
|
-
expected: X[],
|
|
355
|
-
gotten: Y[],
|
|
356
|
-
trace: boolean = false,
|
|
357
|
-
): void => {
|
|
358
|
-
const length: number = Math.min(expected.length, gotten.length);
|
|
359
|
-
expected = expected.slice(0, length);
|
|
360
|
-
gotten = gotten.slice(0, length);
|
|
361
|
-
|
|
362
|
-
const xIds: string[] = get_ids(expected).slice(0, length);
|
|
363
|
-
const yIds: string[] = get_ids(gotten)
|
|
364
|
-
.filter((id) => id >= xIds[0])
|
|
365
|
-
.slice(0, length);
|
|
366
|
-
|
|
367
|
-
const equals: boolean = xIds.every((x, i) => x === yIds[i]);
|
|
368
|
-
if (equals === true) return;
|
|
369
|
-
else if (trace === true)
|
|
370
|
-
console.log({
|
|
371
|
-
expected: xIds,
|
|
372
|
-
gotten: yIds,
|
|
373
|
-
});
|
|
374
|
-
throw new Error(
|
|
375
|
-
`Bug on ${title}: result of the index is different with manual aggregation.`,
|
|
376
|
-
);
|
|
377
|
-
};
|
|
378
|
-
|
|
379
|
-
/**
|
|
380
|
-
* Validates search functionality by testing API results against manual
|
|
381
|
-
* filtering.
|
|
382
|
-
*
|
|
383
|
-
* Comprehensive search validation that samples entities from a complete
|
|
384
|
-
* dataset, extracts search values, applies manual filtering, calls the search
|
|
385
|
-
* API, and compares results. Validates that search APIs return the correct
|
|
386
|
-
* subset of data matching the search criteria.
|
|
387
|
-
*
|
|
388
|
-
* @example
|
|
389
|
-
* ```typescript
|
|
390
|
-
* // Test article search functionality with exact matching
|
|
391
|
-
* const allArticles = await db.articles.findAll();
|
|
392
|
-
* const searchValidator = TestValidator.search(
|
|
393
|
-
* "article search API",
|
|
394
|
-
* (req) => api.searchArticles(req),
|
|
395
|
-
* allArticles,
|
|
396
|
-
* 5 // test with 5 random samples
|
|
397
|
-
* );
|
|
398
|
-
*
|
|
399
|
-
* // Test exact match search
|
|
400
|
-
* await searchValidator({
|
|
401
|
-
* fields: ["title"],
|
|
402
|
-
* values: (article) => [article.title], // full title for exact match
|
|
403
|
-
* filter: (article, [title]) => article.title === title, // exact match
|
|
404
|
-
* request: ([title]) => ({ search: { title } })
|
|
405
|
-
* });
|
|
406
|
-
*
|
|
407
|
-
* // Test partial match search with includes
|
|
408
|
-
* await searchValidator({
|
|
409
|
-
* fields: ["content"],
|
|
410
|
-
* values: (article) => [article.content.substring(0, 20)], // partial content
|
|
411
|
-
* filter: (article, [keyword]) => article.content.includes(keyword),
|
|
412
|
-
* request: ([keyword]) => ({ q: keyword })
|
|
413
|
-
* });
|
|
414
|
-
*
|
|
415
|
-
* // Test multi-field search with exact matching
|
|
416
|
-
* await searchValidator({
|
|
417
|
-
* fields: ["writer", "title"],
|
|
418
|
-
* values: (article) => [article.writer, article.title],
|
|
419
|
-
* filter: (article, [writer, title]) =>
|
|
420
|
-
* article.writer === writer && article.title === title,
|
|
421
|
-
* request: ([writer, title]) => ({ search: { writer, title } })
|
|
422
|
-
* });
|
|
423
|
-
* ```;
|
|
424
|
-
*
|
|
425
|
-
* @param title - Descriptive title used in error messages when search fails
|
|
426
|
-
* @param getter - API function that performs the search
|
|
427
|
-
* @param total - Complete dataset to sample from for testing
|
|
428
|
-
* @param sampleCount - Number of random samples to test (default: 1)
|
|
429
|
-
* @returns A function that accepts search configuration properties
|
|
430
|
-
* @throws Error when API search results don't match manual filtering results
|
|
431
|
-
*/
|
|
432
|
-
export const search =
|
|
433
|
-
<Entity extends IEntity<any>, Request>(
|
|
434
|
-
title: string,
|
|
435
|
-
getter: (input: Request) => Promise<Entity[]>,
|
|
436
|
-
total: Entity[],
|
|
437
|
-
sampleCount: number = 1,
|
|
438
|
-
) =>
|
|
439
|
-
async <Values extends any[]>(
|
|
440
|
-
props: ISearchProps<Entity, Values, Request>,
|
|
441
|
-
) => {
|
|
442
|
-
const samples: Entity[] = RandomGenerator.sample(total, sampleCount);
|
|
443
|
-
for (const s of samples) {
|
|
444
|
-
const values: Values = props.values(s);
|
|
445
|
-
const filtered: Entity[] = total.filter((entity) =>
|
|
446
|
-
props.filter(entity, values),
|
|
447
|
-
);
|
|
448
|
-
const gotten: Entity[] = await getter(props.request(values));
|
|
449
|
-
TestValidator.index(
|
|
450
|
-
`${title} (${props.fields.join(", ")})`,
|
|
451
|
-
filtered,
|
|
452
|
-
gotten,
|
|
453
|
-
);
|
|
454
|
-
}
|
|
455
|
-
};
|
|
456
|
-
|
|
457
|
-
/**
|
|
458
|
-
* Configuration interface for search validation functionality.
|
|
459
|
-
*
|
|
460
|
-
* Defines the structure needed to validate search operations by specifying
|
|
461
|
-
* how to extract search values from entities, filter the dataset manually,
|
|
462
|
-
* and construct API requests.
|
|
463
|
-
*
|
|
464
|
-
* @template Entity - Type of entities being searched, must have an ID field
|
|
465
|
-
* @template Values - Tuple type representing the search values extracted from
|
|
466
|
-
* entities
|
|
467
|
-
* @template Request - Type of the API request object
|
|
468
|
-
*/
|
|
469
|
-
export interface ISearchProps<
|
|
470
|
-
Entity extends IEntity<any>,
|
|
471
|
-
Values extends any[],
|
|
472
|
-
Request,
|
|
473
|
-
> {
|
|
474
|
-
/** Field names being searched, used in error messages for identification */
|
|
475
|
-
fields: string[];
|
|
476
|
-
|
|
477
|
-
/**
|
|
478
|
-
* Extracts search values from a sample entity
|
|
479
|
-
*
|
|
480
|
-
* @param entity - The entity to extract search values from
|
|
481
|
-
* @returns Tuple of values used for searching
|
|
482
|
-
*/
|
|
483
|
-
values(entity: Entity): Values;
|
|
484
|
-
|
|
485
|
-
/**
|
|
486
|
-
* Manual filter function to determine if an entity matches search criteria
|
|
487
|
-
*
|
|
488
|
-
* @param entity - Entity to test against criteria
|
|
489
|
-
* @param values - Search values to match against
|
|
490
|
-
* @returns True if entity matches the search criteria
|
|
491
|
-
*/
|
|
492
|
-
filter(entity: Entity, values: Values): boolean;
|
|
493
|
-
|
|
494
|
-
/**
|
|
495
|
-
* Constructs API request object from search values
|
|
496
|
-
*
|
|
497
|
-
* @param values - Search values to include in request
|
|
498
|
-
* @returns Request object for the search API
|
|
499
|
-
*/
|
|
500
|
-
request(values: Values): Request;
|
|
501
|
-
}
|
|
502
|
-
|
|
503
|
-
/**
|
|
504
|
-
* Validates sorting functionality of pagination APIs.
|
|
505
|
-
*
|
|
506
|
-
* Tests sorting operations by calling the API with sort parameters and
|
|
507
|
-
* validating that results are correctly ordered. Supports multiple fields,
|
|
508
|
-
* ascending/descending order, and optional filtering. Provides detailed error
|
|
509
|
-
* reporting for sorting failures.
|
|
510
|
-
*
|
|
511
|
-
* @example
|
|
512
|
-
* ```typescript
|
|
513
|
-
* // Test single field sorting with GaffComparator
|
|
514
|
-
* const sortValidator = TestValidator.sort(
|
|
515
|
-
* "article sorting",
|
|
516
|
-
* (sortable) => api.getArticles({ sort: sortable })
|
|
517
|
-
* )("created_at")(
|
|
518
|
-
* GaffComparator.dates((a) => a.created_at)
|
|
519
|
-
* );
|
|
520
|
-
*
|
|
521
|
-
* await sortValidator("+"); // ascending
|
|
522
|
-
* await sortValidator("-"); // descending
|
|
523
|
-
*
|
|
524
|
-
* // Test multi-field sorting with GaffComparator
|
|
525
|
-
* const userSortValidator = TestValidator.sort(
|
|
526
|
-
* "user sorting",
|
|
527
|
-
* (sortable) => api.getUsers({ sort: sortable })
|
|
528
|
-
* )("lastName", "firstName")(
|
|
529
|
-
* GaffComparator.strings((user) => [user.lastName, user.firstName]),
|
|
530
|
-
* (user) => user.isActive // only test active users
|
|
531
|
-
* );
|
|
532
|
-
*
|
|
533
|
-
* await userSortValidator("+", true); // ascending with trace logging
|
|
534
|
-
*
|
|
535
|
-
* // Custom comparator for complex logic
|
|
536
|
-
* const customSortValidator = TestValidator.sort(
|
|
537
|
-
* "custom sorting",
|
|
538
|
-
* (sortable) => api.getProducts({ sort: sortable })
|
|
539
|
-
* )("price", "rating")(
|
|
540
|
-
* (a, b) => {
|
|
541
|
-
* const priceDiff = a.price - b.price;
|
|
542
|
-
* return priceDiff !== 0 ? priceDiff : b.rating - a.rating; // price asc, rating desc
|
|
543
|
-
* }
|
|
544
|
-
* );
|
|
545
|
-
* ```;
|
|
546
|
-
*
|
|
547
|
-
* @param title - Descriptive title used in error messages when sorting fails
|
|
548
|
-
* @param getter - API function that fetches sorted data
|
|
549
|
-
* @returns A currying function chain: field names, comparator, then direction
|
|
550
|
-
* @throws Error when API results are not properly sorted according to
|
|
551
|
-
* specification
|
|
552
|
-
*/
|
|
553
|
-
export const sort =
|
|
554
|
-
<
|
|
555
|
-
T extends object,
|
|
556
|
-
Fields extends string,
|
|
557
|
-
Sortable extends Array<`-${Fields}` | `+${Fields}`> = Array<
|
|
558
|
-
`-${Fields}` | `+${Fields}`
|
|
559
|
-
>,
|
|
560
|
-
>(
|
|
561
|
-
title: string,
|
|
562
|
-
getter: (sortable: Sortable) => Promise<T[]>,
|
|
563
|
-
) =>
|
|
564
|
-
(...fields: Fields[]) =>
|
|
565
|
-
(comp: (x: T, y: T) => number, filter?: (elem: T) => boolean) =>
|
|
566
|
-
async (direction: "+" | "-", trace: boolean = false) => {
|
|
567
|
-
let data: T[] = await getter(
|
|
568
|
-
fields.map((field) => `${direction}${field}` as const) as Sortable,
|
|
569
|
-
);
|
|
570
|
-
if (filter) data = data.filter(filter);
|
|
571
|
-
|
|
572
|
-
const reversed: typeof comp =
|
|
573
|
-
direction === "+" ? comp : (x, y) => comp(y, x);
|
|
574
|
-
if (is_sorted(data, reversed) === false) {
|
|
575
|
-
if (
|
|
576
|
-
fields.length === 1 &&
|
|
577
|
-
data.length &&
|
|
578
|
-
(data as any)[0][fields[0]] !== undefined &&
|
|
579
|
-
trace
|
|
580
|
-
)
|
|
581
|
-
console.log(data.map((elem) => (elem as any)[fields[0]]));
|
|
582
|
-
throw new Error(
|
|
583
|
-
`Bug on ${title}: wrong sorting on ${direction}(${fields.join(
|
|
584
|
-
", ",
|
|
585
|
-
)}).`,
|
|
586
|
-
);
|
|
587
|
-
}
|
|
588
|
-
};
|
|
589
|
-
|
|
590
|
-
/**
|
|
591
|
-
* Type alias for sortable field specifications.
|
|
592
|
-
*
|
|
593
|
-
* Represents an array of sort field specifications where each field can be
|
|
594
|
-
* prefixed with '+' for ascending order or '-' for descending order.
|
|
595
|
-
*
|
|
596
|
-
* @example
|
|
597
|
-
* ```typescript
|
|
598
|
-
* type UserSortable = TestValidator.Sortable<"name" | "email" | "created_at">;
|
|
599
|
-
* // Results in: Array<"-name" | "+name" | "-email" | "+email" | "-created_at" | "+created_at">
|
|
600
|
-
*
|
|
601
|
-
* const userSort: UserSortable = ["+name", "-created_at"];
|
|
602
|
-
* ```;
|
|
603
|
-
*
|
|
604
|
-
* @template Literal - String literal type representing available field names
|
|
605
|
-
*/
|
|
606
|
-
export type Sortable<Literal extends string> = Array<
|
|
607
|
-
`-${Literal}` | `+${Literal}`
|
|
608
|
-
>;
|
|
609
|
-
}
|
|
610
|
-
|
|
611
|
-
interface IEntity<Type extends string | number | bigint> {
|
|
612
|
-
id: Type;
|
|
613
|
-
}
|
|
614
|
-
|
|
615
|
-
/** @internal */
|
|
616
|
-
function get_ids<Entity extends IEntity<any>>(entities: Entity[]): string[] {
|
|
617
|
-
return entities.map((entity) => entity.id).sort((x, y) => (x < y ? -1 : 1));
|
|
618
|
-
}
|
|
619
|
-
|
|
620
|
-
/** @internal */
|
|
621
|
-
function is_promise(input: any): input is Promise<any> {
|
|
622
|
-
return (
|
|
623
|
-
typeof input === "object" &&
|
|
624
|
-
input !== null &&
|
|
625
|
-
typeof (input as any).then === "function" &&
|
|
626
|
-
typeof (input as any).catch === "function"
|
|
627
|
-
);
|
|
628
|
-
}
|
|
629
|
-
|
|
630
|
-
/** @internal */
|
|
631
|
-
function is_sorted<T>(data: T[], comp: (x: T, y: T) => number): boolean {
|
|
632
|
-
for (let i: number = 1; i < data.length; ++i)
|
|
633
|
-
if (comp(data[i - 1], data[i]) > 0) return false;
|
|
634
|
-
return true;
|
|
635
|
-
}
|
|
1
|
+
import { RandomGenerator } from "./RandomGenerator";
|
|
2
|
+
import { json_equal_to } from "./internal/json_equal_to";
|
|
3
|
+
|
|
4
|
+
/**
|
|
5
|
+
* A comprehensive collection of E2E validation utilities for testing
|
|
6
|
+
* applications.
|
|
7
|
+
*
|
|
8
|
+
* TestValidator provides type-safe validation functions for common testing
|
|
9
|
+
* scenarios including condition checking, equality validation, error testing,
|
|
10
|
+
* HTTP error validation, pagination testing, search functionality validation,
|
|
11
|
+
* and sorting validation.
|
|
12
|
+
*
|
|
13
|
+
* Most functions use direct parameter passing for simplicity, while some
|
|
14
|
+
* maintain currying patterns for advanced composition. All provide detailed
|
|
15
|
+
* error messages for debugging failed assertions.
|
|
16
|
+
*
|
|
17
|
+
* @author Jeongho Nam - https://github.com/samchon
|
|
18
|
+
* @example
|
|
19
|
+
* ```typescript
|
|
20
|
+
* // Basic condition testing
|
|
21
|
+
* TestValidator.predicate("user should be authenticated", user.isAuthenticated);
|
|
22
|
+
*
|
|
23
|
+
* // Equality validation
|
|
24
|
+
* TestValidator.equals("API response should match expected", x, y);
|
|
25
|
+
*
|
|
26
|
+
* // Error validation
|
|
27
|
+
* TestValidator.error("should throw on invalid input", () => assertInput(""));
|
|
28
|
+
* ```;
|
|
29
|
+
*/
|
|
30
|
+
export namespace TestValidator {
|
|
31
|
+
/**
|
|
32
|
+
* Validates that a given condition evaluates to true.
|
|
33
|
+
*
|
|
34
|
+
* Supports synchronous boolean values, synchronous functions returning
|
|
35
|
+
* boolean, and asynchronous functions returning Promise<boolean>. The return
|
|
36
|
+
* type is automatically inferred based on the input type.
|
|
37
|
+
*
|
|
38
|
+
* @example
|
|
39
|
+
* ```typescript
|
|
40
|
+
* // Synchronous boolean
|
|
41
|
+
* TestValidator.predicate("user should exist", user !== null);
|
|
42
|
+
*
|
|
43
|
+
* // Synchronous function
|
|
44
|
+
* TestValidator.predicate("array should be empty", () => arr.length === 0);
|
|
45
|
+
*
|
|
46
|
+
* // Asynchronous function
|
|
47
|
+
* await TestValidator.predicate("database should be connected",
|
|
48
|
+
* async () => await db.ping()
|
|
49
|
+
* );
|
|
50
|
+
* ```;
|
|
51
|
+
*
|
|
52
|
+
* @param title - Descriptive title used in error messages when validation
|
|
53
|
+
* fails
|
|
54
|
+
* @param condition - The condition to validate (boolean, function, or async
|
|
55
|
+
* function)
|
|
56
|
+
* @returns Void or Promise<void> based on the input type
|
|
57
|
+
* @throws Error with descriptive message when condition is not satisfied
|
|
58
|
+
*/
|
|
59
|
+
export function predicate<
|
|
60
|
+
T extends boolean | (() => boolean) | (() => Promise<boolean>),
|
|
61
|
+
>(
|
|
62
|
+
title: string,
|
|
63
|
+
condition: T,
|
|
64
|
+
): T extends () => Promise<boolean> ? Promise<void> : void {
|
|
65
|
+
const message = () =>
|
|
66
|
+
`Bug on ${title}: expected condition is not satisfied.`;
|
|
67
|
+
|
|
68
|
+
// SCALAR
|
|
69
|
+
if (typeof condition === "boolean") {
|
|
70
|
+
if (condition !== true) throw new Error(message());
|
|
71
|
+
return undefined as any;
|
|
72
|
+
}
|
|
73
|
+
|
|
74
|
+
// CLOSURE
|
|
75
|
+
const output: boolean | Promise<boolean> = condition();
|
|
76
|
+
if (typeof output === "boolean") {
|
|
77
|
+
if (output !== true) throw new Error(message());
|
|
78
|
+
return undefined as any;
|
|
79
|
+
}
|
|
80
|
+
|
|
81
|
+
// ASYNCHRONOUS
|
|
82
|
+
return new Promise<void>((resolve, reject) => {
|
|
83
|
+
output
|
|
84
|
+
.then((flag) => {
|
|
85
|
+
if (flag === true) resolve();
|
|
86
|
+
else reject(message());
|
|
87
|
+
})
|
|
88
|
+
.catch(reject);
|
|
89
|
+
}) as any;
|
|
90
|
+
}
|
|
91
|
+
|
|
92
|
+
/**
|
|
93
|
+
* Validates deep equality between two values using JSON comparison.
|
|
94
|
+
*
|
|
95
|
+
* Performs recursive comparison of objects and arrays. Supports an optional
|
|
96
|
+
* exception filter to ignore specific keys during comparison. Useful for
|
|
97
|
+
* validating API responses, data transformations, and object state changes.
|
|
98
|
+
*
|
|
99
|
+
* @example
|
|
100
|
+
* ```typescript
|
|
101
|
+
* // Basic equality
|
|
102
|
+
* TestValidator.equals("response should match expected", expectedUser, actualUser);
|
|
103
|
+
*
|
|
104
|
+
* // Ignore timestamps in comparison
|
|
105
|
+
* TestValidator.equals("user data should match", expectedUser, actualUser,
|
|
106
|
+
* (key) => key === "updatedAt"
|
|
107
|
+
* );
|
|
108
|
+
*
|
|
109
|
+
* // Validate API response structure
|
|
110
|
+
* TestValidator.equals("API response structure",
|
|
111
|
+
* { id: 1, name: "John" },
|
|
112
|
+
* { id: 1, name: "John" }
|
|
113
|
+
* );
|
|
114
|
+
*
|
|
115
|
+
* // Type-safe nullable comparisons
|
|
116
|
+
* const nullableData: { name: string } | null = getData();
|
|
117
|
+
* TestValidator.equals("nullable check", nullableData, null);
|
|
118
|
+
* ```;
|
|
119
|
+
*
|
|
120
|
+
* @param title - Descriptive title used in error messages when values differ
|
|
121
|
+
* @param X - The first value to compare
|
|
122
|
+
* @param y - The second value to compare (can be null or undefined)
|
|
123
|
+
* @param exception - Optional filter function to exclude specific keys from
|
|
124
|
+
* comparison
|
|
125
|
+
* @throws Error with detailed diff information when values are not equal
|
|
126
|
+
*/
|
|
127
|
+
export function equals<X, Y extends X = X>(
|
|
128
|
+
title: string,
|
|
129
|
+
X: X,
|
|
130
|
+
y: Y | null | undefined,
|
|
131
|
+
exception?: (key: string) => boolean,
|
|
132
|
+
): void {
|
|
133
|
+
const diff: string[] = json_equal_to(exception ?? (() => false))(X)(y);
|
|
134
|
+
if (diff.length)
|
|
135
|
+
throw new Error(
|
|
136
|
+
[
|
|
137
|
+
`Bug on ${title}: found different values - [${diff.join(", ")}]:`,
|
|
138
|
+
"\n",
|
|
139
|
+
JSON.stringify({ x: X, y }, null, 2),
|
|
140
|
+
].join("\n"),
|
|
141
|
+
);
|
|
142
|
+
}
|
|
143
|
+
|
|
144
|
+
/**
|
|
145
|
+
* Validates deep inequality between two values using JSON comparison.
|
|
146
|
+
*
|
|
147
|
+
* Performs recursive comparison of objects and arrays to ensure they are NOT
|
|
148
|
+
* equal. Supports an optional exception filter to ignore specific keys during
|
|
149
|
+
* comparison. Useful for validating that data has changed, objects are
|
|
150
|
+
* different, or mutations have occurred.
|
|
151
|
+
*
|
|
152
|
+
* @example
|
|
153
|
+
* ```typescript
|
|
154
|
+
* // Basic inequality
|
|
155
|
+
* TestValidator.notEquals("user should be different after update", originalUser, updatedUser);
|
|
156
|
+
*
|
|
157
|
+
* // Ignore timestamps in comparison
|
|
158
|
+
* TestValidator.notEquals("user data should differ", originalUser, modifiedUser,
|
|
159
|
+
* (key) => key === "updatedAt"
|
|
160
|
+
* );
|
|
161
|
+
*
|
|
162
|
+
* // Validate state changes
|
|
163
|
+
* TestValidator.notEquals("state should have changed", initialState, currentState);
|
|
164
|
+
*
|
|
165
|
+
* // Type-safe nullable comparisons
|
|
166
|
+
* const mutableData: { count: number } | null = getMutableData();
|
|
167
|
+
* TestValidator.notEquals("should have changed", mutableData, null);
|
|
168
|
+
* ```;
|
|
169
|
+
*
|
|
170
|
+
* @param title - Descriptive title used in error messages when values are
|
|
171
|
+
* equal
|
|
172
|
+
* @param x - The first value to compare
|
|
173
|
+
* @param y - The second value to compare (can be null or undefined)
|
|
174
|
+
* @param exception - Optional filter function to exclude specific keys from
|
|
175
|
+
* comparison
|
|
176
|
+
* @throws Error when values are equal (indicating validation failure)
|
|
177
|
+
*/
|
|
178
|
+
export function notEquals<X, Y extends X = X>(
|
|
179
|
+
title: string,
|
|
180
|
+
x: X,
|
|
181
|
+
y: Y | null | undefined,
|
|
182
|
+
exception?: (key: string) => boolean,
|
|
183
|
+
): void {
|
|
184
|
+
const diff: string[] = json_equal_to(exception ?? (() => false))(x)(y);
|
|
185
|
+
if (diff.length === 0)
|
|
186
|
+
throw new Error(
|
|
187
|
+
[
|
|
188
|
+
`Bug on ${title}: values should be different but are equal:`,
|
|
189
|
+
"\n",
|
|
190
|
+
JSON.stringify({ x, y }, null, 2),
|
|
191
|
+
].join("\n"),
|
|
192
|
+
);
|
|
193
|
+
}
|
|
194
|
+
|
|
195
|
+
/**
|
|
196
|
+
* Validates that a function throws an error or rejects when executed.
|
|
197
|
+
*
|
|
198
|
+
* Expects the provided function to fail. If the function executes
|
|
199
|
+
* successfully without throwing an error or rejecting, this validator will
|
|
200
|
+
* throw an exception. Supports both synchronous and asynchronous functions.
|
|
201
|
+
*
|
|
202
|
+
* @example
|
|
203
|
+
* ```typescript
|
|
204
|
+
* // Synchronous error validation
|
|
205
|
+
* TestValidator.error("should reject invalid email",
|
|
206
|
+
* () => validateEmail("invalid-email")
|
|
207
|
+
* );
|
|
208
|
+
*
|
|
209
|
+
* // Asynchronous error validation
|
|
210
|
+
* await TestValidator.error("should reject unauthorized access",
|
|
211
|
+
* async () => await api.functional.getSecretData()
|
|
212
|
+
* );
|
|
213
|
+
*
|
|
214
|
+
* // Validate input validation
|
|
215
|
+
* TestValidator.error("should throw on empty string",
|
|
216
|
+
* () => processRequiredInput("")
|
|
217
|
+
* );
|
|
218
|
+
* ```;
|
|
219
|
+
*
|
|
220
|
+
* @param title - Descriptive title used in error messages when no error
|
|
221
|
+
* occurs
|
|
222
|
+
* @param task - The function that should throw an error or reject
|
|
223
|
+
* @returns Void or Promise<void> based on the input type
|
|
224
|
+
* @throws Error when the task function does not throw an error or reject
|
|
225
|
+
*/
|
|
226
|
+
export function error<T>(
|
|
227
|
+
title: string,
|
|
228
|
+
task: () => T,
|
|
229
|
+
): T extends Promise<any> ? Promise<void> : void {
|
|
230
|
+
const message = () => `Bug on ${title}: exception must be thrown.`;
|
|
231
|
+
try {
|
|
232
|
+
const output: T = task();
|
|
233
|
+
if (is_promise(output))
|
|
234
|
+
return new Promise<void>((resolve, reject) =>
|
|
235
|
+
output.catch(() => resolve()).then(() => reject(message())),
|
|
236
|
+
) as any;
|
|
237
|
+
else throw new Error(message());
|
|
238
|
+
} catch {
|
|
239
|
+
return undefined as any;
|
|
240
|
+
}
|
|
241
|
+
}
|
|
242
|
+
|
|
243
|
+
/**
|
|
244
|
+
* Validates that a function throws an HTTP error with specific status codes.
|
|
245
|
+
*
|
|
246
|
+
* Specialized error validator for HTTP operations. Validates that the
|
|
247
|
+
* function throws an HttpError with one of the specified status codes. Useful
|
|
248
|
+
* for testing API endpoints, authentication, and authorization logic.
|
|
249
|
+
*
|
|
250
|
+
* @example
|
|
251
|
+
* ```typescript
|
|
252
|
+
* // Validate 401 Unauthorized
|
|
253
|
+
* await TestValidator.httpError("should return 401 for invalid token", 401,
|
|
254
|
+
* async () => await api.functional.getProtectedResource("invalid-token")
|
|
255
|
+
* );
|
|
256
|
+
*
|
|
257
|
+
* // Validate multiple possible error codes
|
|
258
|
+
* await TestValidator.httpError("should return client error", [400, 404, 422],
|
|
259
|
+
* async () => await api.functional.updateNonexistentResource(data)
|
|
260
|
+
* );
|
|
261
|
+
*
|
|
262
|
+
* // Validate server errors
|
|
263
|
+
* TestValidator.httpError("should handle server errors", [500, 502, 503],
|
|
264
|
+
* () => callFaultyEndpoint()
|
|
265
|
+
* );
|
|
266
|
+
* ```;
|
|
267
|
+
*
|
|
268
|
+
* @param title - Descriptive title used in error messages
|
|
269
|
+
* @param status - Expected status code(s), can be a single number or array
|
|
270
|
+
* @param task - The function that should throw an HttpError
|
|
271
|
+
* @returns Void or Promise<void> based on the input type
|
|
272
|
+
* @throws Error when function doesn't throw HttpError or status code doesn't
|
|
273
|
+
* match
|
|
274
|
+
*/
|
|
275
|
+
export function httpError<T>(
|
|
276
|
+
title: string,
|
|
277
|
+
status: number | number[],
|
|
278
|
+
task: () => T,
|
|
279
|
+
): T extends Promise<any> ? Promise<void> : void {
|
|
280
|
+
if (typeof status === "number") status = [status];
|
|
281
|
+
const message = (actual?: number) =>
|
|
282
|
+
typeof actual === "number"
|
|
283
|
+
? `Bug on ${title}: status code must be ${status.join(
|
|
284
|
+
" or ",
|
|
285
|
+
)}, but ${actual}.`
|
|
286
|
+
: `Bug on ${title}: status code must be ${status.join(
|
|
287
|
+
" or ",
|
|
288
|
+
)}, but succeeded.`;
|
|
289
|
+
const predicate = (exp: any): Error | null =>
|
|
290
|
+
typeof exp === "object" &&
|
|
291
|
+
exp.constructor.name === "HttpError" &&
|
|
292
|
+
status.some((val) => val === exp.status)
|
|
293
|
+
? null
|
|
294
|
+
: new Error(
|
|
295
|
+
message(
|
|
296
|
+
typeof exp === "object" && exp.constructor.name === "HttpError"
|
|
297
|
+
? exp.status
|
|
298
|
+
: undefined,
|
|
299
|
+
),
|
|
300
|
+
);
|
|
301
|
+
try {
|
|
302
|
+
const output: T = task();
|
|
303
|
+
if (is_promise(output))
|
|
304
|
+
return new Promise<void>((resolve, reject) =>
|
|
305
|
+
output
|
|
306
|
+
.catch((exp) => {
|
|
307
|
+
const res: Error | null = predicate(exp);
|
|
308
|
+
if (res) reject(res);
|
|
309
|
+
else resolve();
|
|
310
|
+
})
|
|
311
|
+
.then(() => reject(new Error(message()))),
|
|
312
|
+
) as any;
|
|
313
|
+
else throw new Error(message());
|
|
314
|
+
} catch (exp) {
|
|
315
|
+
const res: Error | null = predicate(exp);
|
|
316
|
+
if (res) throw res;
|
|
317
|
+
return undefined!;
|
|
318
|
+
}
|
|
319
|
+
}
|
|
320
|
+
|
|
321
|
+
/**
|
|
322
|
+
* Validates pagination index API results against expected entity order.
|
|
323
|
+
*
|
|
324
|
+
* Compares the order of entities returned by a pagination API with manually
|
|
325
|
+
* sorted expected results. Validates that entity IDs appear in the correct
|
|
326
|
+
* sequence. Commonly used for testing database queries, search results, and
|
|
327
|
+
* any paginated data APIs.
|
|
328
|
+
*
|
|
329
|
+
* @example
|
|
330
|
+
* ```typescript
|
|
331
|
+
* // Test article pagination
|
|
332
|
+
* const expectedArticles = await db.articles.findAll({ order: 'created_at DESC' });
|
|
333
|
+
* const actualArticles = await api.functional.getArticles({ page: 1, limit: 10 });
|
|
334
|
+
*
|
|
335
|
+
* TestValidator.index("article pagination order", expectedArticles, actualArticles,
|
|
336
|
+
* true // enable trace logging
|
|
337
|
+
* );
|
|
338
|
+
*
|
|
339
|
+
* // Test user search results
|
|
340
|
+
* const manuallyFilteredUsers = allUsers.filter(u => u.name.includes("John"));
|
|
341
|
+
* const apiSearchResults = await api.functional.searchUsers({ query: "John" });
|
|
342
|
+
*
|
|
343
|
+
* TestValidator.index("user search results", manuallyFilteredUsers, apiSearchResults);
|
|
344
|
+
* ```;
|
|
345
|
+
*
|
|
346
|
+
* @param title - Descriptive title used in error messages when order differs
|
|
347
|
+
* @param expected - The expected entities in correct order
|
|
348
|
+
* @param gotten - The actual entities returned by the API
|
|
349
|
+
* @param trace - Optional flag to enable debug logging (default: false)
|
|
350
|
+
* @throws Error when entity order differs between expected and actual results
|
|
351
|
+
*/
|
|
352
|
+
export const index = <X extends IEntity<any>, Y extends X = X>(
|
|
353
|
+
title: string,
|
|
354
|
+
expected: X[],
|
|
355
|
+
gotten: Y[],
|
|
356
|
+
trace: boolean = false,
|
|
357
|
+
): void => {
|
|
358
|
+
const length: number = Math.min(expected.length, gotten.length);
|
|
359
|
+
expected = expected.slice(0, length);
|
|
360
|
+
gotten = gotten.slice(0, length);
|
|
361
|
+
|
|
362
|
+
const xIds: string[] = get_ids(expected).slice(0, length);
|
|
363
|
+
const yIds: string[] = get_ids(gotten)
|
|
364
|
+
.filter((id) => id >= xIds[0])
|
|
365
|
+
.slice(0, length);
|
|
366
|
+
|
|
367
|
+
const equals: boolean = xIds.every((x, i) => x === yIds[i]);
|
|
368
|
+
if (equals === true) return;
|
|
369
|
+
else if (trace === true)
|
|
370
|
+
console.log({
|
|
371
|
+
expected: xIds,
|
|
372
|
+
gotten: yIds,
|
|
373
|
+
});
|
|
374
|
+
throw new Error(
|
|
375
|
+
`Bug on ${title}: result of the index is different with manual aggregation.`,
|
|
376
|
+
);
|
|
377
|
+
};
|
|
378
|
+
|
|
379
|
+
/**
|
|
380
|
+
* Validates search functionality by testing API results against manual
|
|
381
|
+
* filtering.
|
|
382
|
+
*
|
|
383
|
+
* Comprehensive search validation that samples entities from a complete
|
|
384
|
+
* dataset, extracts search values, applies manual filtering, calls the search
|
|
385
|
+
* API, and compares results. Validates that search APIs return the correct
|
|
386
|
+
* subset of data matching the search criteria.
|
|
387
|
+
*
|
|
388
|
+
* @example
|
|
389
|
+
* ```typescript
|
|
390
|
+
* // Test article search functionality with exact matching
|
|
391
|
+
* const allArticles = await db.articles.findAll();
|
|
392
|
+
* const searchValidator = TestValidator.search(
|
|
393
|
+
* "article search API",
|
|
394
|
+
* (req) => api.searchArticles(req),
|
|
395
|
+
* allArticles,
|
|
396
|
+
* 5 // test with 5 random samples
|
|
397
|
+
* );
|
|
398
|
+
*
|
|
399
|
+
* // Test exact match search
|
|
400
|
+
* await searchValidator({
|
|
401
|
+
* fields: ["title"],
|
|
402
|
+
* values: (article) => [article.title], // full title for exact match
|
|
403
|
+
* filter: (article, [title]) => article.title === title, // exact match
|
|
404
|
+
* request: ([title]) => ({ search: { title } })
|
|
405
|
+
* });
|
|
406
|
+
*
|
|
407
|
+
* // Test partial match search with includes
|
|
408
|
+
* await searchValidator({
|
|
409
|
+
* fields: ["content"],
|
|
410
|
+
* values: (article) => [article.content.substring(0, 20)], // partial content
|
|
411
|
+
* filter: (article, [keyword]) => article.content.includes(keyword),
|
|
412
|
+
* request: ([keyword]) => ({ q: keyword })
|
|
413
|
+
* });
|
|
414
|
+
*
|
|
415
|
+
* // Test multi-field search with exact matching
|
|
416
|
+
* await searchValidator({
|
|
417
|
+
* fields: ["writer", "title"],
|
|
418
|
+
* values: (article) => [article.writer, article.title],
|
|
419
|
+
* filter: (article, [writer, title]) =>
|
|
420
|
+
* article.writer === writer && article.title === title,
|
|
421
|
+
* request: ([writer, title]) => ({ search: { writer, title } })
|
|
422
|
+
* });
|
|
423
|
+
* ```;
|
|
424
|
+
*
|
|
425
|
+
* @param title - Descriptive title used in error messages when search fails
|
|
426
|
+
* @param getter - API function that performs the search
|
|
427
|
+
* @param total - Complete dataset to sample from for testing
|
|
428
|
+
* @param sampleCount - Number of random samples to test (default: 1)
|
|
429
|
+
* @returns A function that accepts search configuration properties
|
|
430
|
+
* @throws Error when API search results don't match manual filtering results
|
|
431
|
+
*/
|
|
432
|
+
export const search =
|
|
433
|
+
<Entity extends IEntity<any>, Request>(
|
|
434
|
+
title: string,
|
|
435
|
+
getter: (input: Request) => Promise<Entity[]>,
|
|
436
|
+
total: Entity[],
|
|
437
|
+
sampleCount: number = 1,
|
|
438
|
+
) =>
|
|
439
|
+
async <Values extends any[]>(
|
|
440
|
+
props: ISearchProps<Entity, Values, Request>,
|
|
441
|
+
) => {
|
|
442
|
+
const samples: Entity[] = RandomGenerator.sample(total, sampleCount);
|
|
443
|
+
for (const s of samples) {
|
|
444
|
+
const values: Values = props.values(s);
|
|
445
|
+
const filtered: Entity[] = total.filter((entity) =>
|
|
446
|
+
props.filter(entity, values),
|
|
447
|
+
);
|
|
448
|
+
const gotten: Entity[] = await getter(props.request(values));
|
|
449
|
+
TestValidator.index(
|
|
450
|
+
`${title} (${props.fields.join(", ")})`,
|
|
451
|
+
filtered,
|
|
452
|
+
gotten,
|
|
453
|
+
);
|
|
454
|
+
}
|
|
455
|
+
};
|
|
456
|
+
|
|
457
|
+
/**
|
|
458
|
+
* Configuration interface for search validation functionality.
|
|
459
|
+
*
|
|
460
|
+
* Defines the structure needed to validate search operations by specifying
|
|
461
|
+
* how to extract search values from entities, filter the dataset manually,
|
|
462
|
+
* and construct API requests.
|
|
463
|
+
*
|
|
464
|
+
* @template Entity - Type of entities being searched, must have an ID field
|
|
465
|
+
* @template Values - Tuple type representing the search values extracted from
|
|
466
|
+
* entities
|
|
467
|
+
* @template Request - Type of the API request object
|
|
468
|
+
*/
|
|
469
|
+
export interface ISearchProps<
|
|
470
|
+
Entity extends IEntity<any>,
|
|
471
|
+
Values extends any[],
|
|
472
|
+
Request,
|
|
473
|
+
> {
|
|
474
|
+
/** Field names being searched, used in error messages for identification */
|
|
475
|
+
fields: string[];
|
|
476
|
+
|
|
477
|
+
/**
|
|
478
|
+
* Extracts search values from a sample entity
|
|
479
|
+
*
|
|
480
|
+
* @param entity - The entity to extract search values from
|
|
481
|
+
* @returns Tuple of values used for searching
|
|
482
|
+
*/
|
|
483
|
+
values(entity: Entity): Values;
|
|
484
|
+
|
|
485
|
+
/**
|
|
486
|
+
* Manual filter function to determine if an entity matches search criteria
|
|
487
|
+
*
|
|
488
|
+
* @param entity - Entity to test against criteria
|
|
489
|
+
* @param values - Search values to match against
|
|
490
|
+
* @returns True if entity matches the search criteria
|
|
491
|
+
*/
|
|
492
|
+
filter(entity: Entity, values: Values): boolean;
|
|
493
|
+
|
|
494
|
+
/**
|
|
495
|
+
* Constructs API request object from search values
|
|
496
|
+
*
|
|
497
|
+
* @param values - Search values to include in request
|
|
498
|
+
* @returns Request object for the search API
|
|
499
|
+
*/
|
|
500
|
+
request(values: Values): Request;
|
|
501
|
+
}
|
|
502
|
+
|
|
503
|
+
/**
|
|
504
|
+
* Validates sorting functionality of pagination APIs.
|
|
505
|
+
*
|
|
506
|
+
* Tests sorting operations by calling the API with sort parameters and
|
|
507
|
+
* validating that results are correctly ordered. Supports multiple fields,
|
|
508
|
+
* ascending/descending order, and optional filtering. Provides detailed error
|
|
509
|
+
* reporting for sorting failures.
|
|
510
|
+
*
|
|
511
|
+
* @example
|
|
512
|
+
* ```typescript
|
|
513
|
+
* // Test single field sorting with GaffComparator
|
|
514
|
+
* const sortValidator = TestValidator.sort(
|
|
515
|
+
* "article sorting",
|
|
516
|
+
* (sortable) => api.getArticles({ sort: sortable })
|
|
517
|
+
* )("created_at")(
|
|
518
|
+
* GaffComparator.dates((a) => a.created_at)
|
|
519
|
+
* );
|
|
520
|
+
*
|
|
521
|
+
* await sortValidator("+"); // ascending
|
|
522
|
+
* await sortValidator("-"); // descending
|
|
523
|
+
*
|
|
524
|
+
* // Test multi-field sorting with GaffComparator
|
|
525
|
+
* const userSortValidator = TestValidator.sort(
|
|
526
|
+
* "user sorting",
|
|
527
|
+
* (sortable) => api.getUsers({ sort: sortable })
|
|
528
|
+
* )("lastName", "firstName")(
|
|
529
|
+
* GaffComparator.strings((user) => [user.lastName, user.firstName]),
|
|
530
|
+
* (user) => user.isActive // only test active users
|
|
531
|
+
* );
|
|
532
|
+
*
|
|
533
|
+
* await userSortValidator("+", true); // ascending with trace logging
|
|
534
|
+
*
|
|
535
|
+
* // Custom comparator for complex logic
|
|
536
|
+
* const customSortValidator = TestValidator.sort(
|
|
537
|
+
* "custom sorting",
|
|
538
|
+
* (sortable) => api.getProducts({ sort: sortable })
|
|
539
|
+
* )("price", "rating")(
|
|
540
|
+
* (a, b) => {
|
|
541
|
+
* const priceDiff = a.price - b.price;
|
|
542
|
+
* return priceDiff !== 0 ? priceDiff : b.rating - a.rating; // price asc, rating desc
|
|
543
|
+
* }
|
|
544
|
+
* );
|
|
545
|
+
* ```;
|
|
546
|
+
*
|
|
547
|
+
* @param title - Descriptive title used in error messages when sorting fails
|
|
548
|
+
* @param getter - API function that fetches sorted data
|
|
549
|
+
* @returns A currying function chain: field names, comparator, then direction
|
|
550
|
+
* @throws Error when API results are not properly sorted according to
|
|
551
|
+
* specification
|
|
552
|
+
*/
|
|
553
|
+
export const sort =
|
|
554
|
+
<
|
|
555
|
+
T extends object,
|
|
556
|
+
Fields extends string,
|
|
557
|
+
Sortable extends Array<`-${Fields}` | `+${Fields}`> = Array<
|
|
558
|
+
`-${Fields}` | `+${Fields}`
|
|
559
|
+
>,
|
|
560
|
+
>(
|
|
561
|
+
title: string,
|
|
562
|
+
getter: (sortable: Sortable) => Promise<T[]>,
|
|
563
|
+
) =>
|
|
564
|
+
(...fields: Fields[]) =>
|
|
565
|
+
(comp: (x: T, y: T) => number, filter?: (elem: T) => boolean) =>
|
|
566
|
+
async (direction: "+" | "-", trace: boolean = false) => {
|
|
567
|
+
let data: T[] = await getter(
|
|
568
|
+
fields.map((field) => `${direction}${field}` as const) as Sortable,
|
|
569
|
+
);
|
|
570
|
+
if (filter) data = data.filter(filter);
|
|
571
|
+
|
|
572
|
+
const reversed: typeof comp =
|
|
573
|
+
direction === "+" ? comp : (x, y) => comp(y, x);
|
|
574
|
+
if (is_sorted(data, reversed) === false) {
|
|
575
|
+
if (
|
|
576
|
+
fields.length === 1 &&
|
|
577
|
+
data.length &&
|
|
578
|
+
(data as any)[0][fields[0]] !== undefined &&
|
|
579
|
+
trace
|
|
580
|
+
)
|
|
581
|
+
console.log(data.map((elem) => (elem as any)[fields[0]]));
|
|
582
|
+
throw new Error(
|
|
583
|
+
`Bug on ${title}: wrong sorting on ${direction}(${fields.join(
|
|
584
|
+
", ",
|
|
585
|
+
)}).`,
|
|
586
|
+
);
|
|
587
|
+
}
|
|
588
|
+
};
|
|
589
|
+
|
|
590
|
+
/**
|
|
591
|
+
* Type alias for sortable field specifications.
|
|
592
|
+
*
|
|
593
|
+
* Represents an array of sort field specifications where each field can be
|
|
594
|
+
* prefixed with '+' for ascending order or '-' for descending order.
|
|
595
|
+
*
|
|
596
|
+
* @example
|
|
597
|
+
* ```typescript
|
|
598
|
+
* type UserSortable = TestValidator.Sortable<"name" | "email" | "created_at">;
|
|
599
|
+
* // Results in: Array<"-name" | "+name" | "-email" | "+email" | "-created_at" | "+created_at">
|
|
600
|
+
*
|
|
601
|
+
* const userSort: UserSortable = ["+name", "-created_at"];
|
|
602
|
+
* ```;
|
|
603
|
+
*
|
|
604
|
+
* @template Literal - String literal type representing available field names
|
|
605
|
+
*/
|
|
606
|
+
export type Sortable<Literal extends string> = Array<
|
|
607
|
+
`-${Literal}` | `+${Literal}`
|
|
608
|
+
>;
|
|
609
|
+
}
|
|
610
|
+
|
|
611
|
+
interface IEntity<Type extends string | number | bigint> {
|
|
612
|
+
id: Type;
|
|
613
|
+
}
|
|
614
|
+
|
|
615
|
+
/** @internal */
|
|
616
|
+
function get_ids<Entity extends IEntity<any>>(entities: Entity[]): string[] {
|
|
617
|
+
return entities.map((entity) => entity.id).sort((x, y) => (x < y ? -1 : 1));
|
|
618
|
+
}
|
|
619
|
+
|
|
620
|
+
/** @internal */
|
|
621
|
+
function is_promise(input: any): input is Promise<any> {
|
|
622
|
+
return (
|
|
623
|
+
typeof input === "object" &&
|
|
624
|
+
input !== null &&
|
|
625
|
+
typeof (input as any).then === "function" &&
|
|
626
|
+
typeof (input as any).catch === "function"
|
|
627
|
+
);
|
|
628
|
+
}
|
|
629
|
+
|
|
630
|
+
/** @internal */
|
|
631
|
+
function is_sorted<T>(data: T[], comp: (x: T, y: T) => number): boolean {
|
|
632
|
+
for (let i: number = 1; i < data.length; ++i)
|
|
633
|
+
if (comp(data[i - 1], data[i]) > 0) return false;
|
|
634
|
+
return true;
|
|
635
|
+
}
|