@nestia/e2e 12.0.0-rc.2 → 12.0.0-rc.3

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -1,637 +1,637 @@
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
236
- .catch(() => resolve())
237
- .then(() => reject(new Error(message()))),
238
- ) as any;
239
- else throw new Error(message());
240
- } catch {
241
- return undefined as any;
242
- }
243
- }
244
-
245
- /**
246
- * Validates that a function throws an HTTP error with specific status codes.
247
- *
248
- * Specialized error validator for HTTP operations. Validates that the
249
- * function throws an HttpError with one of the specified status codes. Useful
250
- * for testing API endpoints, authentication, and authorization logic.
251
- *
252
- * @example
253
- * ```typescript
254
- * // Validate 401 Unauthorized
255
- * await TestValidator.httpError("should return 401 for invalid token", 401,
256
- * async () => await api.functional.getProtectedResource("invalid-token")
257
- * );
258
- *
259
- * // Validate multiple possible error codes
260
- * await TestValidator.httpError("should return client error", [400, 404, 422],
261
- * async () => await api.functional.updateNonexistentResource(data)
262
- * );
263
- *
264
- * // Validate server errors
265
- * TestValidator.httpError("should handle server errors", [500, 502, 503],
266
- * () => callFaultyEndpoint()
267
- * );
268
- * ```;
269
- *
270
- * @param title - Descriptive title used in error messages
271
- * @param status - Expected status code(s), can be a single number or array
272
- * @param task - The function that should throw an HttpError
273
- * @returns Void or Promise<void> based on the input type
274
- * @throws Error when function doesn't throw HttpError or status code doesn't
275
- * match
276
- */
277
- export function httpError<T>(
278
- title: string,
279
- status: number | number[],
280
- task: () => T,
281
- ): T extends Promise<any> ? Promise<void> : void {
282
- if (typeof status === "number") status = [status];
283
- const message = (actual?: number) =>
284
- typeof actual === "number"
285
- ? `Bug on ${title}: status code must be ${status.join(
286
- " or ",
287
- )}, but ${actual}.`
288
- : `Bug on ${title}: status code must be ${status.join(
289
- " or ",
290
- )}, but succeeded.`;
291
- const predicate = (exp: any): Error | null =>
292
- typeof exp === "object" &&
293
- exp.constructor.name === "HttpError" &&
294
- status.some((val) => val === exp.status)
295
- ? null
296
- : new Error(
297
- message(
298
- typeof exp === "object" && exp.constructor.name === "HttpError"
299
- ? exp.status
300
- : undefined,
301
- ),
302
- );
303
- try {
304
- const output: T = task();
305
- if (is_promise(output))
306
- return new Promise<void>((resolve, reject) =>
307
- output
308
- .catch((exp) => {
309
- const res: Error | null = predicate(exp);
310
- if (res) reject(res);
311
- else resolve();
312
- })
313
- .then(() => reject(new Error(message()))),
314
- ) as any;
315
- else throw new Error(message());
316
- } catch (exp) {
317
- const res: Error | null = predicate(exp);
318
- if (res) throw res;
319
- return undefined!;
320
- }
321
- }
322
-
323
- /**
324
- * Validates pagination index API results against expected entity order.
325
- *
326
- * Compares the order of entities returned by a pagination API with manually
327
- * sorted expected results. Validates that entity IDs appear in the correct
328
- * sequence. Commonly used for testing database queries, search results, and
329
- * any paginated data APIs.
330
- *
331
- * @example
332
- * ```typescript
333
- * // Test article pagination
334
- * const expectedArticles = await db.articles.findAll({ order: 'created_at DESC' });
335
- * const actualArticles = await api.functional.getArticles({ page: 1, limit: 10 });
336
- *
337
- * TestValidator.index("article pagination order", expectedArticles, actualArticles,
338
- * true // enable trace logging
339
- * );
340
- *
341
- * // Test user search results
342
- * const manuallyFilteredUsers = allUsers.filter(u => u.name.includes("John"));
343
- * const apiSearchResults = await api.functional.searchUsers({ query: "John" });
344
- *
345
- * TestValidator.index("user search results", manuallyFilteredUsers, apiSearchResults);
346
- * ```;
347
- *
348
- * @param title - Descriptive title used in error messages when order differs
349
- * @param expected - The expected entities in correct order
350
- * @param gotten - The actual entities returned by the API
351
- * @param trace - Optional flag to enable debug logging (default: false)
352
- * @throws Error when entity order differs between expected and actual results
353
- */
354
- export const index = <X extends IEntity<any>, Y extends X = X>(
355
- title: string,
356
- expected: X[],
357
- gotten: Y[],
358
- trace: boolean = false,
359
- ): void => {
360
- const length: number = Math.min(expected.length, gotten.length);
361
- expected = expected.slice(0, length);
362
- gotten = gotten.slice(0, length);
363
-
364
- const xIds: string[] = get_ids(expected).slice(0, length);
365
- const yIds: string[] = get_ids(gotten)
366
- .filter((id) => id >= xIds[0]!)
367
- .slice(0, length);
368
-
369
- const equals: boolean = xIds.every((x, i) => x === yIds[i]);
370
- if (equals === true) return;
371
- else if (trace === true)
372
- console.log({
373
- expected: xIds,
374
- gotten: yIds,
375
- });
376
- throw new Error(
377
- `Bug on ${title}: result of the index is different with manual aggregation.`,
378
- );
379
- };
380
-
381
- /**
382
- * Validates search functionality by testing API results against manual
383
- * filtering.
384
- *
385
- * Comprehensive search validation that samples entities from a complete
386
- * dataset, extracts search values, applies manual filtering, calls the search
387
- * API, and compares results. Validates that search APIs return the correct
388
- * subset of data matching the search criteria.
389
- *
390
- * @example
391
- * ```typescript
392
- * // Test article search functionality with exact matching
393
- * const allArticles = await db.articles.findAll();
394
- * const searchValidator = TestValidator.search(
395
- * "article search API",
396
- * (req) => api.searchArticles(req),
397
- * allArticles,
398
- * 5 // test with 5 random samples
399
- * );
400
- *
401
- * // Test exact match search
402
- * await searchValidator({
403
- * fields: ["title"],
404
- * values: (article) => [article.title], // full title for exact match
405
- * filter: (article, [title]) => article.title === title, // exact match
406
- * request: ([title]) => ({ search: { title } })
407
- * });
408
- *
409
- * // Test partial match search with includes
410
- * await searchValidator({
411
- * fields: ["content"],
412
- * values: (article) => [article.content.substring(0, 20)], // partial content
413
- * filter: (article, [keyword]) => article.content.includes(keyword),
414
- * request: ([keyword]) => ({ q: keyword })
415
- * });
416
- *
417
- * // Test multi-field search with exact matching
418
- * await searchValidator({
419
- * fields: ["writer", "title"],
420
- * values: (article) => [article.writer, article.title],
421
- * filter: (article, [writer, title]) =>
422
- * article.writer === writer && article.title === title,
423
- * request: ([writer, title]) => ({ search: { writer, title } })
424
- * });
425
- * ```;
426
- *
427
- * @param title - Descriptive title used in error messages when search fails
428
- * @param getter - API function that performs the search
429
- * @param total - Complete dataset to sample from for testing
430
- * @param sampleCount - Number of random samples to test (default: 1)
431
- * @returns A function that accepts search configuration properties
432
- * @throws Error when API search results don't match manual filtering results
433
- */
434
- export const search =
435
- <Entity extends IEntity<any>, Request>(
436
- title: string,
437
- getter: (input: Request) => Promise<Entity[]>,
438
- total: Entity[],
439
- sampleCount: number = 1,
440
- ) =>
441
- async <Values extends any[]>(
442
- props: ISearchProps<Entity, Values, Request>,
443
- ) => {
444
- const samples: Entity[] = RandomGenerator.sample(total, sampleCount);
445
- for (const s of samples) {
446
- const values: Values = props.values(s);
447
- const filtered: Entity[] = total.filter((entity) =>
448
- props.filter(entity, values),
449
- );
450
- const gotten: Entity[] = await getter(props.request(values));
451
- TestValidator.index(
452
- `${title} (${props.fields.join(", ")})`,
453
- filtered,
454
- gotten,
455
- );
456
- }
457
- };
458
-
459
- /**
460
- * Configuration interface for search validation functionality.
461
- *
462
- * Defines the structure needed to validate search operations by specifying
463
- * how to extract search values from entities, filter the dataset manually,
464
- * and construct API requests.
465
- *
466
- * @template Entity - Type of entities being searched, must have an ID field
467
- * @template Values - Tuple type representing the search values extracted from
468
- * entities
469
- * @template Request - Type of the API request object
470
- */
471
- export interface ISearchProps<
472
- Entity extends IEntity<any>,
473
- Values extends any[],
474
- Request,
475
- > {
476
- /** Field names being searched, used in error messages for identification */
477
- fields: string[];
478
-
479
- /**
480
- * Extracts search values from a sample entity
481
- *
482
- * @param entity - The entity to extract search values from
483
- * @returns Tuple of values used for searching
484
- */
485
- values(entity: Entity): Values;
486
-
487
- /**
488
- * Manual filter function to determine if an entity matches search criteria
489
- *
490
- * @param entity - Entity to test against criteria
491
- * @param values - Search values to match against
492
- * @returns True if entity matches the search criteria
493
- */
494
- filter(entity: Entity, values: Values): boolean;
495
-
496
- /**
497
- * Constructs API request object from search values
498
- *
499
- * @param values - Search values to include in request
500
- * @returns Request object for the search API
501
- */
502
- request(values: Values): Request;
503
- }
504
-
505
- /**
506
- * Validates sorting functionality of pagination APIs.
507
- *
508
- * Tests sorting operations by calling the API with sort parameters and
509
- * validating that results are correctly ordered. Supports multiple fields,
510
- * ascending/descending order, and optional filtering. Provides detailed error
511
- * reporting for sorting failures.
512
- *
513
- * @example
514
- * ```typescript
515
- * // Test single field sorting with GaffComparator
516
- * const sortValidator = TestValidator.sort(
517
- * "article sorting",
518
- * (sortable) => api.getArticles({ sort: sortable })
519
- * )("created_at")(
520
- * GaffComparator.dates((a) => a.created_at)
521
- * );
522
- *
523
- * await sortValidator("+"); // ascending
524
- * await sortValidator("-"); // descending
525
- *
526
- * // Test multi-field sorting with GaffComparator
527
- * const userSortValidator = TestValidator.sort(
528
- * "user sorting",
529
- * (sortable) => api.getUsers({ sort: sortable })
530
- * )("lastName", "firstName")(
531
- * GaffComparator.strings((user) => [user.lastName, user.firstName]),
532
- * (user) => user.isActive // only test active users
533
- * );
534
- *
535
- * await userSortValidator("+", true); // ascending with trace logging
536
- *
537
- * // Custom comparator for complex logic
538
- * const customSortValidator = TestValidator.sort(
539
- * "custom sorting",
540
- * (sortable) => api.getProducts({ sort: sortable })
541
- * )("price", "rating")(
542
- * (a, b) => {
543
- * const priceDiff = a.price - b.price;
544
- * return priceDiff !== 0 ? priceDiff : b.rating - a.rating; // price asc, rating desc
545
- * }
546
- * );
547
- * ```;
548
- *
549
- * @param title - Descriptive title used in error messages when sorting fails
550
- * @param getter - API function that fetches sorted data
551
- * @returns A currying function chain: field names, comparator, then direction
552
- * @throws Error when API results are not properly sorted according to
553
- * specification
554
- */
555
- export const sort =
556
- <
557
- T extends object,
558
- Fields extends string,
559
- Sortable extends Array<`-${Fields}` | `+${Fields}`> = Array<
560
- `-${Fields}` | `+${Fields}`
561
- >,
562
- >(
563
- title: string,
564
- getter: (sortable: Sortable) => Promise<T[]>,
565
- ) =>
566
- (...fields: Fields[]) =>
567
- (comp: (x: T, y: T) => number, filter?: (elem: T) => boolean) =>
568
- async (direction: "+" | "-", trace: boolean = false) => {
569
- let data: T[] = await getter(
570
- fields.map((field) => `${direction}${field}` as const) as Sortable,
571
- );
572
- if (filter) data = data.filter(filter);
573
-
574
- const reversed: typeof comp =
575
- direction === "+" ? comp : (x, y) => comp(y, x);
576
- if (is_sorted(data, reversed) === false) {
577
- if (
578
- fields.length === 1 &&
579
- data.length &&
580
- (data as any)[0][fields[0]] !== undefined &&
581
- trace
582
- )
583
- console.log(data.map((elem) => (elem as any)[fields[0]]));
584
- throw new Error(
585
- `Bug on ${title}: wrong sorting on ${direction}(${fields.join(
586
- ", ",
587
- )}).`,
588
- );
589
- }
590
- };
591
-
592
- /**
593
- * Type alias for sortable field specifications.
594
- *
595
- * Represents an array of sort field specifications where each field can be
596
- * prefixed with '+' for ascending order or '-' for descending order.
597
- *
598
- * @example
599
- * ```typescript
600
- * type UserSortable = TestValidator.Sortable<"name" | "email" | "created_at">;
601
- * // Results in: Array<"-name" | "+name" | "-email" | "+email" | "-created_at" | "+created_at">
602
- *
603
- * const userSort: UserSortable = ["+name", "-created_at"];
604
- * ```;
605
- *
606
- * @template Literal - String literal type representing available field names
607
- */
608
- export type Sortable<Literal extends string> = Array<
609
- `-${Literal}` | `+${Literal}`
610
- >;
611
- }
612
-
613
- interface IEntity<Type extends string | number | bigint> {
614
- id: Type;
615
- }
616
-
617
- /** @internal */
618
- function get_ids<Entity extends IEntity<any>>(entities: Entity[]): string[] {
619
- return entities.map((entity) => entity.id).sort((x, y) => (x < y ? -1 : 1));
620
- }
621
-
622
- /** @internal */
623
- function is_promise(input: any): input is Promise<any> {
624
- return (
625
- typeof input === "object" &&
626
- input !== null &&
627
- typeof (input as any).then === "function" &&
628
- typeof (input as any).catch === "function"
629
- );
630
- }
631
-
632
- /** @internal */
633
- function is_sorted<T>(data: T[], comp: (x: T, y: T) => number): boolean {
634
- for (let i: number = 1; i < data.length; ++i)
635
- if (comp(data[i - 1]!, data[i]!) > 0) return false;
636
- return true;
637
- }
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
236
+ .catch(() => resolve())
237
+ .then(() => reject(new Error(message()))),
238
+ ) as any;
239
+ else throw new Error(message());
240
+ } catch {
241
+ return undefined as any;
242
+ }
243
+ }
244
+
245
+ /**
246
+ * Validates that a function throws an HTTP error with specific status codes.
247
+ *
248
+ * Specialized error validator for HTTP operations. Validates that the
249
+ * function throws an HttpError with one of the specified status codes. Useful
250
+ * for testing API endpoints, authentication, and authorization logic.
251
+ *
252
+ * @example
253
+ * ```typescript
254
+ * // Validate 401 Unauthorized
255
+ * await TestValidator.httpError("should return 401 for invalid token", 401,
256
+ * async () => await api.functional.getProtectedResource("invalid-token")
257
+ * );
258
+ *
259
+ * // Validate multiple possible error codes
260
+ * await TestValidator.httpError("should return client error", [400, 404, 422],
261
+ * async () => await api.functional.updateNonexistentResource(data)
262
+ * );
263
+ *
264
+ * // Validate server errors
265
+ * TestValidator.httpError("should handle server errors", [500, 502, 503],
266
+ * () => callFaultyEndpoint()
267
+ * );
268
+ * ```;
269
+ *
270
+ * @param title - Descriptive title used in error messages
271
+ * @param status - Expected status code(s), can be a single number or array
272
+ * @param task - The function that should throw an HttpError
273
+ * @returns Void or Promise<void> based on the input type
274
+ * @throws Error when function doesn't throw HttpError or status code doesn't
275
+ * match
276
+ */
277
+ export function httpError<T>(
278
+ title: string,
279
+ status: number | number[],
280
+ task: () => T,
281
+ ): T extends Promise<any> ? Promise<void> : void {
282
+ if (typeof status === "number") status = [status];
283
+ const message = (actual?: number) =>
284
+ typeof actual === "number"
285
+ ? `Bug on ${title}: status code must be ${status.join(
286
+ " or ",
287
+ )}, but ${actual}.`
288
+ : `Bug on ${title}: status code must be ${status.join(
289
+ " or ",
290
+ )}, but succeeded.`;
291
+ const predicate = (exp: any): Error | null =>
292
+ typeof exp === "object" &&
293
+ exp.constructor.name === "HttpError" &&
294
+ status.some((val) => val === exp.status)
295
+ ? null
296
+ : new Error(
297
+ message(
298
+ typeof exp === "object" && exp.constructor.name === "HttpError"
299
+ ? exp.status
300
+ : undefined,
301
+ ),
302
+ );
303
+ try {
304
+ const output: T = task();
305
+ if (is_promise(output))
306
+ return new Promise<void>((resolve, reject) =>
307
+ output
308
+ .catch((exp) => {
309
+ const res: Error | null = predicate(exp);
310
+ if (res) reject(res);
311
+ else resolve();
312
+ })
313
+ .then(() => reject(new Error(message()))),
314
+ ) as any;
315
+ else throw new Error(message());
316
+ } catch (exp) {
317
+ const res: Error | null = predicate(exp);
318
+ if (res) throw res;
319
+ return undefined!;
320
+ }
321
+ }
322
+
323
+ /**
324
+ * Validates pagination index API results against expected entity order.
325
+ *
326
+ * Compares the order of entities returned by a pagination API with manually
327
+ * sorted expected results. Validates that entity IDs appear in the correct
328
+ * sequence. Commonly used for testing database queries, search results, and
329
+ * any paginated data APIs.
330
+ *
331
+ * @example
332
+ * ```typescript
333
+ * // Test article pagination
334
+ * const expectedArticles = await db.articles.findAll({ order: 'created_at DESC' });
335
+ * const actualArticles = await api.functional.getArticles({ page: 1, limit: 10 });
336
+ *
337
+ * TestValidator.index("article pagination order", expectedArticles, actualArticles,
338
+ * true // enable trace logging
339
+ * );
340
+ *
341
+ * // Test user search results
342
+ * const manuallyFilteredUsers = allUsers.filter(u => u.name.includes("John"));
343
+ * const apiSearchResults = await api.functional.searchUsers({ query: "John" });
344
+ *
345
+ * TestValidator.index("user search results", manuallyFilteredUsers, apiSearchResults);
346
+ * ```;
347
+ *
348
+ * @param title - Descriptive title used in error messages when order differs
349
+ * @param expected - The expected entities in correct order
350
+ * @param gotten - The actual entities returned by the API
351
+ * @param trace - Optional flag to enable debug logging (default: false)
352
+ * @throws Error when entity order differs between expected and actual results
353
+ */
354
+ export const index = <X extends IEntity<any>, Y extends X = X>(
355
+ title: string,
356
+ expected: X[],
357
+ gotten: Y[],
358
+ trace: boolean = false,
359
+ ): void => {
360
+ const length: number = Math.min(expected.length, gotten.length);
361
+ expected = expected.slice(0, length);
362
+ gotten = gotten.slice(0, length);
363
+
364
+ const xIds: string[] = get_ids(expected).slice(0, length);
365
+ const yIds: string[] = get_ids(gotten)
366
+ .filter((id) => id >= xIds[0]!)
367
+ .slice(0, length);
368
+
369
+ const equals: boolean = xIds.every((x, i) => x === yIds[i]);
370
+ if (equals === true) return;
371
+ else if (trace === true)
372
+ console.log({
373
+ expected: xIds,
374
+ gotten: yIds,
375
+ });
376
+ throw new Error(
377
+ `Bug on ${title}: result of the index is different with manual aggregation.`,
378
+ );
379
+ };
380
+
381
+ /**
382
+ * Validates search functionality by testing API results against manual
383
+ * filtering.
384
+ *
385
+ * Comprehensive search validation that samples entities from a complete
386
+ * dataset, extracts search values, applies manual filtering, calls the search
387
+ * API, and compares results. Validates that search APIs return the correct
388
+ * subset of data matching the search criteria.
389
+ *
390
+ * @example
391
+ * ```typescript
392
+ * // Test article search functionality with exact matching
393
+ * const allArticles = await db.articles.findAll();
394
+ * const searchValidator = TestValidator.search(
395
+ * "article search API",
396
+ * (req) => api.searchArticles(req),
397
+ * allArticles,
398
+ * 5 // test with 5 random samples
399
+ * );
400
+ *
401
+ * // Test exact match search
402
+ * await searchValidator({
403
+ * fields: ["title"],
404
+ * values: (article) => [article.title], // full title for exact match
405
+ * filter: (article, [title]) => article.title === title, // exact match
406
+ * request: ([title]) => ({ search: { title } })
407
+ * });
408
+ *
409
+ * // Test partial match search with includes
410
+ * await searchValidator({
411
+ * fields: ["content"],
412
+ * values: (article) => [article.content.substring(0, 20)], // partial content
413
+ * filter: (article, [keyword]) => article.content.includes(keyword),
414
+ * request: ([keyword]) => ({ q: keyword })
415
+ * });
416
+ *
417
+ * // Test multi-field search with exact matching
418
+ * await searchValidator({
419
+ * fields: ["writer", "title"],
420
+ * values: (article) => [article.writer, article.title],
421
+ * filter: (article, [writer, title]) =>
422
+ * article.writer === writer && article.title === title,
423
+ * request: ([writer, title]) => ({ search: { writer, title } })
424
+ * });
425
+ * ```;
426
+ *
427
+ * @param title - Descriptive title used in error messages when search fails
428
+ * @param getter - API function that performs the search
429
+ * @param total - Complete dataset to sample from for testing
430
+ * @param sampleCount - Number of random samples to test (default: 1)
431
+ * @returns A function that accepts search configuration properties
432
+ * @throws Error when API search results don't match manual filtering results
433
+ */
434
+ export const search =
435
+ <Entity extends IEntity<any>, Request>(
436
+ title: string,
437
+ getter: (input: Request) => Promise<Entity[]>,
438
+ total: Entity[],
439
+ sampleCount: number = 1,
440
+ ) =>
441
+ async <Values extends any[]>(
442
+ props: ISearchProps<Entity, Values, Request>,
443
+ ) => {
444
+ const samples: Entity[] = RandomGenerator.sample(total, sampleCount);
445
+ for (const s of samples) {
446
+ const values: Values = props.values(s);
447
+ const filtered: Entity[] = total.filter((entity) =>
448
+ props.filter(entity, values),
449
+ );
450
+ const gotten: Entity[] = await getter(props.request(values));
451
+ TestValidator.index(
452
+ `${title} (${props.fields.join(", ")})`,
453
+ filtered,
454
+ gotten,
455
+ );
456
+ }
457
+ };
458
+
459
+ /**
460
+ * Configuration interface for search validation functionality.
461
+ *
462
+ * Defines the structure needed to validate search operations by specifying
463
+ * how to extract search values from entities, filter the dataset manually,
464
+ * and construct API requests.
465
+ *
466
+ * @template Entity - Type of entities being searched, must have an ID field
467
+ * @template Values - Tuple type representing the search values extracted from
468
+ * entities
469
+ * @template Request - Type of the API request object
470
+ */
471
+ export interface ISearchProps<
472
+ Entity extends IEntity<any>,
473
+ Values extends any[],
474
+ Request,
475
+ > {
476
+ /** Field names being searched, used in error messages for identification */
477
+ fields: string[];
478
+
479
+ /**
480
+ * Extracts search values from a sample entity
481
+ *
482
+ * @param entity - The entity to extract search values from
483
+ * @returns Tuple of values used for searching
484
+ */
485
+ values(entity: Entity): Values;
486
+
487
+ /**
488
+ * Manual filter function to determine if an entity matches search criteria
489
+ *
490
+ * @param entity - Entity to test against criteria
491
+ * @param values - Search values to match against
492
+ * @returns True if entity matches the search criteria
493
+ */
494
+ filter(entity: Entity, values: Values): boolean;
495
+
496
+ /**
497
+ * Constructs API request object from search values
498
+ *
499
+ * @param values - Search values to include in request
500
+ * @returns Request object for the search API
501
+ */
502
+ request(values: Values): Request;
503
+ }
504
+
505
+ /**
506
+ * Validates sorting functionality of pagination APIs.
507
+ *
508
+ * Tests sorting operations by calling the API with sort parameters and
509
+ * validating that results are correctly ordered. Supports multiple fields,
510
+ * ascending/descending order, and optional filtering. Provides detailed error
511
+ * reporting for sorting failures.
512
+ *
513
+ * @example
514
+ * ```typescript
515
+ * // Test single field sorting with GaffComparator
516
+ * const sortValidator = TestValidator.sort(
517
+ * "article sorting",
518
+ * (sortable) => api.getArticles({ sort: sortable })
519
+ * )("created_at")(
520
+ * GaffComparator.dates((a) => a.created_at)
521
+ * );
522
+ *
523
+ * await sortValidator("+"); // ascending
524
+ * await sortValidator("-"); // descending
525
+ *
526
+ * // Test multi-field sorting with GaffComparator
527
+ * const userSortValidator = TestValidator.sort(
528
+ * "user sorting",
529
+ * (sortable) => api.getUsers({ sort: sortable })
530
+ * )("lastName", "firstName")(
531
+ * GaffComparator.strings((user) => [user.lastName, user.firstName]),
532
+ * (user) => user.isActive // only test active users
533
+ * );
534
+ *
535
+ * await userSortValidator("+", true); // ascending with trace logging
536
+ *
537
+ * // Custom comparator for complex logic
538
+ * const customSortValidator = TestValidator.sort(
539
+ * "custom sorting",
540
+ * (sortable) => api.getProducts({ sort: sortable })
541
+ * )("price", "rating")(
542
+ * (a, b) => {
543
+ * const priceDiff = a.price - b.price;
544
+ * return priceDiff !== 0 ? priceDiff : b.rating - a.rating; // price asc, rating desc
545
+ * }
546
+ * );
547
+ * ```;
548
+ *
549
+ * @param title - Descriptive title used in error messages when sorting fails
550
+ * @param getter - API function that fetches sorted data
551
+ * @returns A currying function chain: field names, comparator, then direction
552
+ * @throws Error when API results are not properly sorted according to
553
+ * specification
554
+ */
555
+ export const sort =
556
+ <
557
+ T extends object,
558
+ Fields extends string,
559
+ Sortable extends Array<`-${Fields}` | `+${Fields}`> = Array<
560
+ `-${Fields}` | `+${Fields}`
561
+ >,
562
+ >(
563
+ title: string,
564
+ getter: (sortable: Sortable) => Promise<T[]>,
565
+ ) =>
566
+ (...fields: Fields[]) =>
567
+ (comp: (x: T, y: T) => number, filter?: (elem: T) => boolean) =>
568
+ async (direction: "+" | "-", trace: boolean = false) => {
569
+ let data: T[] = await getter(
570
+ fields.map((field) => `${direction}${field}` as const) as Sortable,
571
+ );
572
+ if (filter) data = data.filter(filter);
573
+
574
+ const reversed: typeof comp =
575
+ direction === "+" ? comp : (x, y) => comp(y, x);
576
+ if (is_sorted(data, reversed) === false) {
577
+ if (
578
+ fields.length === 1 &&
579
+ data.length &&
580
+ (data as any)[0][fields[0]] !== undefined &&
581
+ trace
582
+ )
583
+ console.log(data.map((elem) => (elem as any)[fields[0]]));
584
+ throw new Error(
585
+ `Bug on ${title}: wrong sorting on ${direction}(${fields.join(
586
+ ", ",
587
+ )}).`,
588
+ );
589
+ }
590
+ };
591
+
592
+ /**
593
+ * Type alias for sortable field specifications.
594
+ *
595
+ * Represents an array of sort field specifications where each field can be
596
+ * prefixed with '+' for ascending order or '-' for descending order.
597
+ *
598
+ * @example
599
+ * ```typescript
600
+ * type UserSortable = TestValidator.Sortable<"name" | "email" | "created_at">;
601
+ * // Results in: Array<"-name" | "+name" | "-email" | "+email" | "-created_at" | "+created_at">
602
+ *
603
+ * const userSort: UserSortable = ["+name", "-created_at"];
604
+ * ```;
605
+ *
606
+ * @template Literal - String literal type representing available field names
607
+ */
608
+ export type Sortable<Literal extends string> = Array<
609
+ `-${Literal}` | `+${Literal}`
610
+ >;
611
+ }
612
+
613
+ interface IEntity<Type extends string | number | bigint> {
614
+ id: Type;
615
+ }
616
+
617
+ /** @internal */
618
+ function get_ids<Entity extends IEntity<any>>(entities: Entity[]): string[] {
619
+ return entities.map((entity) => entity.id).sort((x, y) => (x < y ? -1 : 1));
620
+ }
621
+
622
+ /** @internal */
623
+ function is_promise(input: any): input is Promise<any> {
624
+ return (
625
+ typeof input === "object" &&
626
+ input !== null &&
627
+ typeof (input as any).then === "function" &&
628
+ typeof (input as any).catch === "function"
629
+ );
630
+ }
631
+
632
+ /** @internal */
633
+ function is_sorted<T>(data: T[], comp: (x: T, y: T) => number): boolean {
634
+ for (let i: number = 1; i < data.length; ++i)
635
+ if (comp(data[i - 1]!, data[i]!) > 0) return false;
636
+ return true;
637
+ }