• Home
  • Features
  • Pricing
  • Docs
  • Announcements
  • Sign In

wp-graphql / wp-graphql-testcase / #236

03 May 2024 01:30AM UTC coverage: 80.342% (-8.3%) from 88.636%
#236

push

web-flow
Merge pull request #33 from wp-graphql/devops/codecoverage-configurations

devops: Codecoverage configurations updated

8 of 16 new or added lines in 2 files covered. (50.0%)

282 of 351 relevant lines covered (80.34%)

3.31 hits per line

Source File
Press 'n' to go to next uncovered line, 'b' for previous

76.47
/src/Constraint/QueryConstraint.php
1
<?php
2
/**
3
 * QueryConstraint interface
4
 *
5
 * Defines shared logic for QueryConstraint classes.
6
 * @since v3.0.0
7
 * @package Tests\WPGraphQL\Constraint
8
 */
9

10
namespace Tests\WPGraphQL\Constraint;
11

12
use PHPUnit\Framework\Exception;
13
use PHPUnit\Framework\Constraint\Constraint;
14
use Tests\WPGraphQL\Logger\CodeceptLogger;
15
use Tests\WPGraphQL\Logger\PHPUnitLogger;
16
use Tests\WPGraphQL\Utils\Utils;
17

18
class QueryConstraint extends Constraint {
19

20
    /**
21
     * Logger for logging debug messages.
22
     *
23
     * @var PHPUnitLogger|CodeceptLogger
24
     */
25
    protected $logger;
26

27
    /**
28
     * Stores the validation steps for the assertion.
29
     * 
30
     * @var array $validationRules 
31
     */
32
    protected $validationRules = [];
33

34
    /**
35
     * Stores subset of response data to be evaluated.
36
     *
37
     * @var mixed
38
     */
39
    private $actual = null;
40

41
    /**
42
     * List of errors trigger during validation.
43
     *
44
     * @var string[]
45
     */
46
    private $error_messages = [];
47

48
    /**
49
     * Constructor
50
     *
51
     * @param array $expected  Expected validation rules.
52
     */
53
    public function __construct($logger, array $expected = []) {
54
        $this->logger = $logger;
22✔
55
        $this->validationRules = $expected;
22✔
56
    }
57

58
    /**
59
         * Reports an error identified by $message if $response is not a valid GraphQL response object.
60
         *
61
         * @param array  $response  GraphQL query response object.
62
         * @param string $message   References that outputs error message.
63
         *
64
         * @return bool
65
         */
66
        protected function responseIsValid( $response, &$message = null ) {
67
                if ( empty( $response ) ) {
19✔
NEW
68
            $this->error_messages[] = 'GraphQL query response is invalid.';
×
69
            return false;
×
70
                }
71

72
                if ( array_keys( $response ) === range( 0, count( $response ) - 1 ) ) {
19✔
73
            $this->error_messages[] = 'The GraphQL query response must be provided as an associative array.';
4✔
74
            return false;
4✔
75
                }
76

77
                if ( 0 === count( array_intersect( array_keys( $response ), [ 'data', 'errors' ] ) ) ) {
16✔
78
            $this->error_messages[] = 'A valid GraphQL query response must contain a "data" or "errors" object.';
1✔
79
            return false;
1✔
80
                }
81

82
                return true;
15✔
83
        }
84

85
    /**
86
         * Evaluates the response "data" against a validation rule.
87
         *
88
         * @param array  $response       GraphQL query response object
89
         * @param array  $expected_data  Validation Rule.valid rule object provided for evaluation.
90
         *
91
         * @return bool
92
         */
93
        protected function expectedDataFound( array $response, array $expected_data, string $current_path = null ) {
94
                // Throw if "$expected_data" invalid.
95
                if ( empty( $expected_data['type'] ) ) {
7✔
96
                        $this->logger->logData( [ 'INVALID_DATA_OBJECT' => $expected_data ] );
×
NEW
97
                        $this->error_messages[] = "Invalid rule object provided for evaluation: \n\t " . json_encode( $expected_data, JSON_PRETTY_PRINT );
×
NEW
98
                        return false;
×
99
                }
100

101
                // Deconstruct $expected_data.
102
                extract( $expected_data );
7✔
103

104
                // Get flags.
105
                $check_order = isset( $expected_index ) && ! is_null( $expected_index );
7✔
106

107
                // Set current path in response.
108
                if ( empty( $current_path ) ) {
7✔
109
                        $path = "data.{$path}";
7✔
110
                } else {
111
                        $path = "{$current_path}.{$path}";
4✔
112
                }
113

114
                // Add index to path if provided.
115
                $full_path = $check_order ? "{$path}.{$expected_index}" : "{$path}";
7✔
116

117
                // Get data at path for evaluation.
118
                $actual_data = $this->getPossibleDataAtPath( $response, $full_path, $is_group );
7✔
119

120
                // Set actual data for final assertion
121
                $this->actual = $actual_data;
7✔
122

123
                // Handle if "$expected_value" set to field value constants.
124
                $reverse             = str_starts_with( $type, '!' );
7✔
125
                $possible_constraint = is_array( $expected_value ) ? $expected_value : "{$expected_value}";
7✔
126
                switch( $possible_constraint ) {
127
                        case $this->logger::IS_NULL:
7✔
128
                                // Set "expected_value" to "null" for later comparison.
129
                                $expected_value = null;
3✔
130
                                break;
3✔
131

132
                        case $this->logger::NOT_FALSY:
7✔
133
                                // Fail if data found at path is a falsy value (null, false, []).
134
                                if ( empty( $actual_data ) && ! $reverse ) {
2✔
135
                                        $this->error_messages[] = sprintf(
×
136
                        'Expected data at path "%s" not to be falsy value. "%s" Given',
×
137
                        $full_path,
×
138
                        is_array( $actual_data ) ? '[]' : (string) $actual_data
×
139
                    );
×
140

141
                                        return false;
×
142
                                } elseif ( ! empty( $actual_data ) && $reverse ) {
2✔
143
                                        $this->error_messages[] = sprintf(
×
144
                                                'Expected data at path "%s" to be falsy value. "%s" Given',
×
145
                                                $full_path,
×
NEW
146
                                                is_array( $actual_data ) ? "\n\n" . json_encode( $actual_data, JSON_PRETTY_PRINT ) : $actual_data
×
147
                                        );
×
148

149
                                        return false;
×
150
                                }
151

152
                                // Return true because target value not falsy.
153
                                return true;
2✔
154

155
                        case $this->logger::IS_FALSY:
7✔
156
                                // Fail if data found at path is not falsy value (null, false, 0, []).
157
                                if ( ! empty( $actual_data ) && ! $reverse ) {
1✔
158
                                        $this->error_messages[] = sprintf(
×
159
                                                'Expected data at path "%s" to be falsy value. "%s" Given',
×
160
                                                $full_path,
×
NEW
161
                                                is_array( $actual_data ) ? "\n\n" .json_encode( $actual_data, JSON_PRETTY_PRINT ) : $actual_data
×
162
                                        );
×
163

164
                                        return false;
×
165
                                } elseif ( empty( $actual_data ) && $reverse ) {
1✔
166
                                        $this->error_messages[] = sprintf(
×
167
                                                'Expected data at path "%s" not to be falsy value. "%s" Given',
×
168
                                                $full_path,
×
NEW
169
                                                is_array( $actual_data ) ? "\n\n" .json_encode( $actual_data, JSON_PRETTY_PRINT ) : $actual_data
×
170
                                        );
×
171

172
                                        return false;
×
173
                                }
174

175
                                // Return true because target value is falsy.
176
                                return true;
1✔
177

178
                        case $this->logger::NOT_NULL:
7✔
179
                        default: // Check if "$expected_value" is not null if comparing to provided value.
180
                                // Fail if no data found at path.
181
                                if ( is_null( $actual_data ) && ! $reverse ) {
7✔
182
                                        $this->error_messages[] = sprintf( 'No data found at path "%s"', $full_path );
×
183

184
                                        return false;
×
185
                                } elseif (
186
                                        ! is_null( $actual_data )
7✔
187
                                        && $reverse
188
                                        && $expected_value === $this->logger::NOT_NULL
7✔
189
                                ) {
190
                                        $this->error_messages[] = sprintf( 'Unexpected data found at path "%s"', $full_path );
×
191

192
                                        return false;
×
193
                                }
194

195
                                // Return true because target value not null.
196
                                if ( $expected_value === $this->logger::NOT_NULL ) {
7✔
197
                                        return true;
2✔
198
                                }
199
                }
200

201
                $match_wanted   = ! str_starts_with( $type, '!' );
7✔
202
                $is_field_rule  = str_ends_with( $type, 'FIELD' );
7✔
203
                $is_object_rule = str_ends_with( $type, 'OBJECT' );
7✔
204
                $is_node_rule   = str_ends_with( $type, 'NODE' );
7✔
205
                $is_edge_rule   = str_ends_with( $type, 'EDGE' );
7✔
206

207
                // Set matcher and constraint.
208
                $matcher                 = ( ( $is_group && $is_field_rule ) || ( ! $check_order && ! $is_field_rule ) )
7✔
209
                        ? 'doesFieldMatchGroup'
6✔
210
                        : 'doesFieldMatch';
5✔
211

212
                // Evaluate rule by type.
213
                switch( true ) {
214
                        case $is_field_rule:
7✔
215
                                // Fail if matcher fails
216
                                if ( ! $this->{$matcher}( $actual_data, $expected_value, $match_wanted, $path ) ) {
7✔
217
                                        $this->error_messages[] = sprintf(
1✔
218
                        'Data found at path "%1$s" %2$s the provided value',
1✔
219
                        $path,
1✔
220
                        $match_wanted ? 'doesn\'t match' : 'shouldn\'t match'
1✔
221
                    );
1✔
222

223
                                        return false;
1✔
224
                                }
225

226
                                // Pass if matcher passes.
227
                                return true;
6✔
228
                        case $is_object_rule:
4✔
229
                        case $is_node_rule:
4✔
230
                        case $is_edge_rule:
1✔
231
                                // Handle nested rules recursively.
232
                                if ( is_array( $expected_value ) && $this->isNested( $expected_value ) ) {
4✔
233
                                        foreach ( $expected_value as $nested_rule ) {
4✔
234
                                                $next_path           = ( $check_order || $is_object_rule ) ? $full_path : "{$full_path}.#";
4✔
235
                                                $next_path          .= $is_edge_rule ? '.node' : '';
4✔
236
                                                $nested_rule_passing = $this->expectedDataFound( $response, $nested_rule, $next_path );
4✔
237

238
                                                if ( ! $nested_rule_passing ) {
4✔
239
                                                        return false;
×
240
                                                }
241
                                        }
242
                                        return true;
4✔
243
                                }
244

245
                                // Fail if matcher fails.
246
                                if ( ! $this->{$matcher}( $actual_data, $expected_value, $match_wanted, $path ) ) {
1✔
247
                                        if ( $check_order ) {
×
248
                                                $this->error_messages[] = sprintf(
×
249
                            'Data found at path "%1$s" %2$s the provided value',
×
250
                            $full_path,
×
251
                            $match_wanted ? 'doesn\'t match' : 'shouldn\'t match'
×
252
                        );
×
253
                                        } else {
254
                                                $this->error_messages[] = sprintf(
×
255
                            '%1$s found in %2$s list at path "%3$s"',
×
256
                            $match_wanted ? 'Unexpected data ' : 'Expected data not ',
×
257
                            strtolower( $type ),
×
258
                            $full_path
×
259
                        );
×
260
                                        }
261

262
                                        return false;
×
263
                                }
264

265
                                // Pass if matcher passes.
266
                                return true;
1✔
267
                        default:
268
                                $this->logger->logData( ['INVALID_DATA_OBJECT', $expected_data ] );
×
NEW
269
                                $this->error_messages[] = "Invalid data object provided for evaluation. \n\t" . json_encode( $expected_data, JSON_PRETTY_PRINT );
×
NEW
270
                                return false;
×
271
                }
272
        }
273

274
    /**
275
         * Evaluates the response "errors" against a validation rule.
276
         *
277
         * @param array  $response       GraphQL query response object
278
         * @param array  $expected_data  Expected data object to be evaluated.
279
         * @param string $message        Error message.
280
     * 
281
     * @throws Exception Invalid data object provided for evaluation.
282
     * 
283
     * @return bool
284
         */
285
        protected function expectedErrorFound( array $response, array $expected_data ) {
286
                $search_type_messages = [
3✔
287
                        $this->logger::MESSAGE_EQUALS      => 'equals',
3✔
288
                        $this->logger::MESSAGE_CONTAINS    => 'contains',
3✔
289
                        $this->logger::MESSAGE_STARTS_WITH => 'starts with',
3✔
290
                        $this->logger::MESSAGE_ENDS_WITH   => 'ends with',
3✔
291
                ];
3✔
292

293
                // Deconstruct $expected_data.
294
                extract( $expected_data );
3✔
295

296
                switch( $type ) {
297
                        case 'ERROR_PATH':
3✔
298
                                $target_path = array_map(
3✔
299
                                        function( $v ) {
3✔
300
                                                return is_numeric( $v ) ? absint( $v ) : $v;
3✔
301
                                        },
3✔
302
                                        explode( '.', $path )
3✔
303
                                );
3✔
304

305
                                // Set constraint.
306
                                $this->actual = $this->getPossibleDataAtPath( $response['errors'], '.#.path' );
3✔
307
                                $this->logger->logData(
3✔
308
                                        [
3✔
309
                                                'TARGET_ERROR_PATH' => $target_path,
3✔
310
                                                'POSSIBLE_ERRORS'   => $this->actual,
3✔
311
                                        ]
3✔
312
                                );
3✔
313
                                foreach ( $response['errors'] as $error ) {
3✔
314
                                        if ( empty( $error['path'] ) ) {
3✔
315
                                                continue;
×
316
                                        }
317

318
                                        // Pass if match found.
319
                                        // TODO: Add support for group path searches using "#" for index.
320
                                        if ( $target_path === $error['path'] ) {
3✔
321
                                                $this->logger->logData(
2✔
322
                                                        [
2✔
323
                                                                'TARGET_ERROR_PATH' => $target_path,
2✔
324
                                                                'CURRENT_PATH'      => $error['path'],
2✔
325
                                                        ]
2✔
326
                                                );
2✔
327
                                                return true;
2✔
328
                                        }
329
                                }
330

331
                                // Fail if no match found.
332
                                $this->error_messages[] = sprintf( 'No errors found that occured at path "%1$s"', $path );
1✔
333
                                return false;
1✔
334
                        case 'ERROR_MESSAGE':
3✔
335
                                $this->logger->logData(
3✔
336
                                        [
3✔
337
                                                'TARGET_ERROR_MESSAGE' => $needle,
3✔
338
                                                'SEARCH_TYPE'          => $search_type_messages[ $search_type ],
3✔
339
                                                'POSSIBLE_ERRORS'      => $response['errors'],
3✔
340
                                        ]
3✔
341
                                );
3✔
342
                                foreach ( $response['errors'] as $error ) {
3✔
343
                                        // Set constraint.
344
                                        $this->actual = $this->getPossibleDataAtPath( $response['errors'], '.#.message' );
3✔
345
                                        
346
                                        if ( empty( $error['message'] ) ) {
3✔
347
                                                continue;
×
348
                                        }
349

350
                                        // Pass if match found.
351
                                        $this->logger->logData(
3✔
352
                                                [
3✔
353
                                                        'TARGET_ERROR_MESSAGE'  => $needle,
3✔
354
                                                        'SEARCH_TYPE'           => $search_type_messages[ $search_type ],
3✔
355
                                                        'CURRENT_ERROR_MESSAGE' => $error['message'],
3✔
356
                                                ]
3✔
357
                                        );
3✔
358
                                        if ( $this->findSubstring( $error['message'], $needle, $search_type ) ) {
3✔
359
                                                return true;
2✔
360
                                        }
361
                                }
362

363
                                // Fail if no match found.
364
                                $this->error_messages[] = sprintf(
1✔
365
                    'No errors found with a message that %1$s "%2$s"',
1✔
366
                    $search_type_messages[ $search_type ],
1✔
367
                    $needle
1✔
368
                );
1✔
369

370
                                return false;
1✔
371
                        default:
372
                                $this->logger->logData( ['INVALID_DATA_OBJECT', $expected_data ] );
1✔
373
                                $this->error_messages[] = "Invalid data object provided for evaluation. \n\t" . json_encode( $expected_data, JSON_PRETTY_PRINT );
1✔
374
                                return false;
1✔
375
                }
376
        }
377

378
    /**
379
         * Returns array of possible values for paths where "#" is being used instead of numeric index
380
         * in $path.
381
         *
382
         * @param array $data        Data to be search
383
         * @param string $path       Formatted lodash path.
384
         * @param boolean $is_group  Function passback.
385
         *
386
         * @return mixed
387
         */
388
        protected function getPossibleDataAtPath( array $data, string $path, &$is_group = false ) {
389
                $branches = explode( '.#', $path );
7✔
390

391
                if ( 1 < count( $branches ) ) {
7✔
392
                        $is_group      = true;
7✔
393
                        $possible_data = $this->lodashGet( $data, $branches[0] );
7✔
394

395
                        // Loop throw top branches and build out the possible data options.
396
                        if ( ! empty( $possible_data ) && is_array( $possible_data ) ) {
7✔
397
                                foreach ( $possible_data as &$next_data ) {
6✔
398
                                        if ( ! is_array( $next_data ) ) {
6✔
399
                                                continue;
×
400
                                        }
401

402
                                        $next_data = $this->getPossibleDataAtPath(
6✔
403
                                                $next_data,
6✔
404
                                                ltrim( implode( '.#', array_slice( $branches, 1 ) ), '.' ),
6✔
405
                                                $is_group
6✔
406
                                        );
6✔
407
                                }
408
                        }
409

410
                        return $possible_data;
7✔
411
                }
412

413
                return $this->lodashGet( $data, $path, null );
7✔
414
        }
415

416
    /**
417
         * The value returned for undefined resolved values.
418
         *
419
         * Clone of the "get" function from the Lodash JS libra
420
         *
421
         * @param array  $object   The object to query.
422
         * @param string $path     The path of the property to get.
423
         * @param mixed  $default  The value returned for undefined resolved values.
424
         * 
425
         * @return mixed
426
         */
427
        protected function lodashGet( array $data, string $string, $default = null ) {
428
                return Utils::lodashGet( $data, $string, $default );
7✔
429
        }
430

431
    /**
432
         * Checks if the provided is a expected data rule object.
433
         *
434
         * @param array $expected_data
435
         *
436
         * @return bool
437
         */
438
        protected function isNested( array $expected_data ) {
439
                $rule_keys = [ 'type', 'path', 'expected_value' ];
4✔
440

441
                return ! empty( $expected_data[0] )
4✔
442
                        && is_array( $expected_data[0] )
4✔
443
                        && 3 === count( array_intersect( array_keys( $expected_data[0] ), $rule_keys ) );
4✔
444
        }
445

446
    /**
447
         * Asserts if $expected_value matches $data.
448
         *
449
         * @param array  $data            Data object be evaluted.
450
         * @param mixed  $expected_value  Value $data is expected to evalute to.
451
         * @param bool   $match_wanted    Whether $expected_value and $data should be equal or different.
452
         * @param string $path The path of the property to get.
453
         *
454
         * @return bool
455
         */
456
        protected function doesFieldMatch( $data, $expected_value, $match_wanted, $path ) {
457
                // Get data/value type and log assertion.
458
                $log_type   = is_array( $data ) ? 'ACTUAL_DATA_OBJECT' : 'ACTUAL_DATA';
7✔
459
                $value_type = $match_wanted ? 'WANTED_VALUE': 'UNWANTED_VALUE';
7✔
460
                $this->logger->logData(
7✔
461
                        array(
7✔
462
                                'PATH'      => $path,
7✔
463
                                $value_type => $expected_value,
7✔
464
                                $log_type   => $data,
7✔
465
                        )
7✔
466
                );
7✔
467

468
                // If match wanted, matching condition set other not matching condition is set.
469
                $condition = $match_wanted
7✔
470
                        ? $data === $expected_value
7✔
471
                        : $data !== $expected_value;
2✔
472

473
                // Return condtion.
474
                return $condition;
7✔
475
        }
476

477
        /**
478
         * Asserts if $expected_value matches one of the entries in $data.
479
         *
480
         * @param array  $data            Data object be evaluted.
481
         * @param mixed  $expected_value  Value $data is expected to evalute to.
482
         * @param bool   $match_wanted    Whether $expected_value and $data should be equal or different.
483
         * @param string $path The path of the property to get.
484
         *
485
         * @return bool
486
         */
487
        protected function doesFieldMatchGroup( $data, $expected_value, $match_wanted, $path ) {
488
                $item_type  = $match_wanted ? 'WANTED VALUE' : 'UNWANTED VALUE';
6✔
489

490
                // Log data objects before the coming assertion.
491
                $assertion_log = [
6✔
492
                        'PATH'               => $path,
6✔
493
                        $item_type           => $expected_value,
6✔
494
                        'VALUES_AT_LOCATION' => $data,
6✔
495
                ];
6✔
496
                $this->logger->logData( $assertion_log );
6✔
497

498
                if ( ! is_array( $data ) ) {
6✔
499
                        return $this->doesFieldMatch(
×
500
                                $data,
×
501
                                $expected_value,
×
502
                                $match_wanted,
×
503
                                $path
×
504
                        );
×
505
                }
506

507
                // Loop through possible node/edge values for the field.
508
                foreach ( $data as $item ) {
6✔
509
                        // Check if field value matches $expected_value.
510
                        $field_matches = $this->doesFieldMatch(
6✔
511
                                $item,
6✔
512
                                $expected_value,
6✔
513
                                $match_wanted,
6✔
514
                                $path
6✔
515
                        );
6✔
516

517
                        // Pass if match found and match wanted.
518
                        if ( $field_matches && $match_wanted ) {
6✔
519
                                return true;
4✔
520

521
                                // Fail if match found and no matches wanted.
522
                        } elseif ( ! $field_matches && ! $match_wanted ) {
4✔
523
                                return false;
×
524
                        }
525
                }
526

527
                // Fail if no matches found but matches wanted.
528
                if ( $match_wanted ) {
3✔
529
                        return false;
1✔
530
                }
531

532
                // Pass if no matches found and no matches wanted.
533
                return true;
2✔
534
        }
535

536
    /**
537
         * Processes substring searches
538
         *
539
         * @param string $needle       String being searched for.
540
         * @param string $haystack     String being searched.
541
         * @param int    $search_type  Search operation enumeration.
542
         *
543
         * @return boolean
544
         */
545
        protected function findSubstring( $haystack, $needle, $search_type ) {
546
                switch( $search_type ) {
547
                        case $this->logger::MESSAGE_EQUALS:
3✔
548
                                return $needle === $haystack;
1✔
549
                        case $this->logger::MESSAGE_CONTAINS:
3✔
550
                                return false !== strpos( $haystack, $needle );
3✔
551
                        case $this->logger::MESSAGE_STARTS_WITH:
1✔
552
                                return str_starts_with( $haystack, $needle );
1✔
553
                        case $this->logger::MESSAGE_ENDS_WITH:
1✔
554
                                return str_ends_with( $haystack, $needle );
1✔
555
                }
556
        }
557

558
    /**
559
     * Evaluates the response against the validation rules.
560
     *
561
     * @param array $response
562
     *
563
     * @throws Exception
564
     * 
565
     * @return boolean
566
     */
567
    public function matches($response): bool {
568
        // Ensure response is valid.
569
        if ( ! $this->responseIsValid( $response ) ) {
5✔
570
            return false;
2✔
571
        }
572

573
        return true;
3✔
574
    }
575

576
    public function failureDescription($other): string {
577
        return "GraphQL response failed validation: \n\n\t• " . implode( "\n\n\t• ", $this->error_messages );
1✔
578
    }
579

580
    /**
581
     * Returns a string representation of the constraint object.
582
     *
583
     * @return string
584
     */
585
    public function toString(): string {
586
        return 'is a valid WPGraphQL response';
1✔
587
    }
588
}
STATUS · Troubleshooting · Open an Issue · Sales · Support · CAREERS · ENTERPRISE · START FREE · SCHEDULE DEMO
ANNOUNCEMENTS · TWITTER · TOS & SLA · Supported CI Services · What's a CI service? · Automated Testing

© 2025 Coveralls, Inc