Code Coverage
 
Lines
Functions and Methods
Classes and Traits
Total
96.38% covered (success)
96.38%
266 / 276
80.00% covered (warning)
80.00%
8 / 10
CRAP
0.00% covered (danger)
0.00%
0 / 1
SurveyFactory
96.38% covered (success)
96.38%
266 / 276
80.00% covered (warning)
80.00%
8 / 10
77
0.00% covered (danger)
0.00%
0 / 1
 __construct
100.00% covered (success)
100.00%
1 / 1
100.00% covered (success)
100.00%
1 / 1
1
 parseSurveyConfig
100.00% covered (success)
100.00%
11 / 11
100.00% covered (success)
100.00%
1 / 1
6
 validateUniqueName
93.75% covered (success)
93.75%
15 / 16
0.00% covered (danger)
0.00%
0 / 1
7.01
 newSurvey
100.00% covered (success)
100.00%
7 / 7
100.00% covered (success)
100.00%
1 / 1
3
 validateSpec
100.00% covered (success)
100.00%
26 / 26
100.00% covered (success)
100.00%
1 / 1
16
 validatePlatforms
100.00% covered (success)
100.00%
18 / 18
100.00% covered (success)
100.00%
1 / 1
5
 validateExternalSurveyQuestions
100.00% covered (success)
100.00%
22 / 22
100.00% covered (success)
100.00%
1 / 1
5
 validateInternalSurveyQuestions
100.00% covered (success)
100.00%
72 / 72
100.00% covered (success)
100.00%
1 / 1
17
 factoryExternal
82.00% covered (warning)
82.00%
41 / 50
0.00% covered (danger)
0.00%
0 / 1
8.37
 factoryInternal
100.00% covered (success)
100.00%
53 / 53
100.00% covered (success)
100.00%
1 / 1
9
1<?php
2
3namespace QuickSurveys;
4
5use InvalidArgumentException;
6use Psr\Log\LoggerInterface;
7
8class SurveyFactory {
9    private const VALID_PLATFORM_MODES = [
10        'desktop' => [
11            'stable',
12        ],
13        'mobile' => [
14            'stable',
15            'beta',
16        ],
17    ];
18
19    private LoggerInterface $logger;
20
21    /**
22     * Inject services.
23     */
24    public function __construct( LoggerInterface $logger ) {
25        $this->logger = $logger;
26    }
27
28    /**
29     * @param array[] $specs Raw configuration from $wgQuickSurveysConfig
30     * @return Survey[] List of valid and enabled surveys
31     */
32    public function parseSurveyConfig( array $specs ): array {
33        if ( !array_is_list( $specs ) ) {
34            $this->logger->error( 'Bad surveys configuration: The surveys configuration is not a list.' );
35
36            return [];
37        }
38
39        $surveys = [];
40        foreach ( $specs as $spec ) {
41            $enabled = $spec['enabled'] ?? false;
42            if ( $this->validateUniqueName( $spec, $specs ) && $enabled ) {
43                $survey = $this->newSurvey( $spec );
44                if ( $survey ) {
45                    $surveys[] = $survey;
46                }
47            }
48        }
49        return $surveys;
50    }
51
52    /**
53     * checks QuickSurveys name for duplications
54     *
55     * @param array $spec
56     * @param array[] $specs
57     * @return bool
58     */
59    private function validateUniqueName( array $spec, array $specs ): bool {
60        if ( !isset( $spec[ 'name' ] ) ) {
61            $this->logger->error( "Bad survey configuration: The survey name does not have a value",
62                        [ 'exception' => "Bad survey configuration: The survey name does not have a value" ] );
63            return false;
64        }
65        if ( count( $specs ) < 2 ) {
66            return true;
67        }
68
69        $name = trim( $spec[ 'name' ] );
70        $numberDuplicates = 0;
71
72        foreach ( $specs as $specArray ) {
73            // if there is more than one copy of the item, it is a duplicate, enter log message
74            if ( ( $specArray['enabled'] ?? false ) &&
75                strcasecmp( trim( $specArray['name'] ?? '' ), $name ) === 0 &&
76                $numberDuplicates++
77            ) {
78                // write out to logger
79                $this->logger->error( "Bad survey configuration: The survey name \"{$name}\" is not unique",
80                                    [ 'exception' => "The \"{$name}\" survey name is not unique" ] );
81                return false;
82            }
83        }
84
85        return true;
86    }
87
88    /**
89     * Creates an instance of either the InternalSurvey or ExternalSurvey class
90     * given a specification.
91     *
92     * An exception is thrown if any of the following conditions aren't met:
93     *
94     * <ul>
95     *   <li>A survey must have a question</li>
96     *   <li>A survey must have a description</li>
97     *   <li>A survey's type must be either "internal" or "external"</li>
98     *   <li>A survey must have a coverage</li>
99     *   <li>An internal survey must have a set of questions</li>
100     *   <li>An external survey must have a privacy policy</li>
101     *   <li>An internal survey must have a layout of either "single-answer" or "multiple-answer"</li>
102     * </ul>
103     *
104     * @param array $spec
105     * @return Survey|null
106     */
107    public function newSurvey( array $spec ): ?Survey {
108        try {
109            $this->validateSpec( $spec );
110            return $spec['type'] === 'internal'
111                ? $this->factoryInternal( $spec )
112                : $this->factoryExternal( $spec );
113        } catch ( InvalidArgumentException $ex ) {
114            $this->logger->error( "Bad survey configuration: " . $ex->getMessage(), [ 'exception' => $ex ] );
115            return null;
116        }
117    }
118
119    /**
120     * @param array $spec
121     * @throws InvalidArgumentException
122     */
123    private function validateSpec( array $spec ): void {
124        $name = $spec['name'];
125
126        if ( !isset( $spec['question'] ) && ( !isset( $spec['questions'] ) || !$spec['questions'] ) ) {
127            throw new InvalidArgumentException( "The \"{$name}\" survey doesn't have a question." );
128        }
129
130        if (
131            !isset( $spec['type'] )
132            || ( $spec['type'] !== 'internal' && $spec['type'] !== 'external' )
133        ) {
134            throw new InvalidArgumentException(
135                "The \"{$name}\" survey isn't marked as internal or external."
136            );
137        }
138
139        if ( !isset( $spec['coverage'] ) ) {
140            throw new InvalidArgumentException( "The \"{$name}\" survey doesn't have a coverage." );
141        }
142
143        if ( !isset( $spec['platforms'] ) ) {
144            throw new InvalidArgumentException( "The \"{$name}\" survey doesn't have any platforms." );
145        }
146
147        if ( $spec['type'] === 'external' ) {
148            $link = null;
149            if ( isset( $spec['link'] ) ) {
150                $link = $spec['link'];
151            } elseif (
152                isset( $spec['questions'] ) &&
153                isset( $spec['questions'][0] ) &&
154                isset( $spec['questions'][0]['link'] )
155            ) {
156                $link = $spec['questions'][0]['link'];
157            }
158
159            if ( $link !== null ) {
160                $url = wfMessage( $link )->inContentLanguage()->plain();
161                $bit = parse_url( $url, PHP_URL_SCHEME );
162
163                if ( $bit !== 'https' ) {
164                    throw new InvalidArgumentException( "The \"{$name}\" external survey must have a secure url." );
165                }
166            }
167        }
168
169        $this->validatePlatforms( $spec );
170    }
171
172    /**
173     * @param array $spec
174     * @throws InvalidArgumentException
175     */
176    private function validatePlatforms( array $spec ): void {
177        foreach ( self::VALID_PLATFORM_MODES as $platform => $validModes ) {
178            if ( !isset( $spec['platforms'][$platform] ) ) {
179                continue;
180            }
181
182            $modes = $spec['platforms'][$platform];
183
184            if (
185                !is_array( $modes ) ||
186                array_diff(
187                    $modes,
188                    array_intersect(
189                        $validModes,
190                        $modes
191                    )
192                )
193            ) {
194                throw new InvalidArgumentException(
195                    "The \"{$spec['name']}\" survey has specified an invalid platform. " .
196                    "Please specify one or more of the following for the \"{$platform}\" platform: " .
197                    implode( ', ', $validModes ) .
198                    '.'
199                );
200            }
201        }
202    }
203
204    /**
205     * @param array $spec
206     * @throws InvalidArgumentException
207     */
208    private function validateExternalSurveyQuestions( array $spec ): void {
209        $surveyName = $spec['name'];
210        $questions = $spec['questions'] ?? [];
211
212        if ( count( $questions ) !== 1 ) {
213            throw new InvalidArgumentException(
214                "The \"{$surveyName}\" external survey should only have one question."
215            );
216        }
217
218        $question = $questions[0];
219
220        $name = $question['name'] ?? null;
221        if ( !$name ) {
222            throw new InvalidArgumentException(
223                "The \"{$surveyName}\" external survey doesn't have a question name."
224            );
225        }
226
227        $questionText = $question['question'] ?? null;
228        if ( !$questionText ) {
229            throw new InvalidArgumentException(
230                "The \"{$surveyName}\" external survey doesn't have a question."
231            );
232        }
233
234        $link = $question['link'] ?? null;
235        if ( !$link ) {
236            throw new InvalidArgumentException(
237                "The \"{$surveyName}\" external survey doesn't have a link."
238            );
239        }
240    }
241
242    /**
243     * @param array $spec
244     * @throws InvalidArgumentException
245     */
246    private function validateInternalSurveyQuestions( array $spec ): void {
247        $surveyName = $spec['name'];
248        $questions = $spec['questions'] ?? [];
249        $questionsByName = [];
250
251        foreach ( $questions as $key => $question ) {
252            $name = $question['name'] ?? null;
253            if ( !$name ) {
254                throw new InvalidArgumentException(
255                    "Question at index \"{$key}\" in the \"{$surveyName}\" internal survey " .
256                    "doesn't have a name."
257                );
258            }
259
260            if ( array_key_exists( $name, $questionsByName ) ) {
261                throw new InvalidArgumentException(
262                    "Question at index \"{$key}\" in the \"{$surveyName}\" internal survey " .
263                    "has a name that's used by a previous question."
264                );
265            }
266            $questionsByName[$name] = $question;
267
268            $layout = $question['layout'] ?? null;
269            if ( !$layout || !in_array( $layout, [ 'single-answer', 'multiple-answer' ] ) ) {
270                throw new InvalidArgumentException(
271                    "Question at index \"{$key}\" in the \"{$surveyName}\" internal survey " .
272                    "has a layout that's not one of \"single-answer\" or \"multiple-answer\"."
273                );
274            }
275
276            $surveyQuestion = $question['question'] ?? null;
277            if ( !$surveyQuestion ) {
278                throw new InvalidArgumentException(
279                    "Question at index \"{$key}\" in the \"{$surveyName}\" internal survey " .
280                    "doesn't have a question."
281                );
282            }
283
284            $answers = $question['answers'] ?? [];
285            if ( !$answers ) {
286                throw new InvalidArgumentException(
287                    "Question at index \"{$key}\" in the \"{$surveyName}\" internal survey " .
288                    "has no answers."
289                );
290            }
291            foreach ( $answers as $answer ) {
292                $label = $answer['label'] ?? null;
293                if ( $label === null ) {
294                    throw new InvalidArgumentException(
295                        "Question at index \"{$key}\" in the \"{$surveyName}\" internal survey " .
296                        "has an answer with no label."
297                    );
298                }
299            }
300
301            $dependsOn = $question['dependsOn'] ?? [];
302            if ( $dependsOn ) {
303                foreach ( $dependsOn as $dependency ) {
304                    $dependencyName = $dependency['question'] ?? null;
305                    $answerIsOneOf = $dependency['answerIsOneOf'] ?? [];
306
307                    if ( !$dependencyName ) {
308                        throw new InvalidArgumentException(
309                            "Question at index \"{$key}\" in the \"{$surveyName}\" internal survey " .
310                            "has a dependency that is not referencing any question."
311                        );
312                    }
313
314                    if ( !array_key_exists( $dependencyName, $questionsByName ) ) {
315                        throw new InvalidArgumentException(
316                            "Question at index \"{$key}\" in the \"{$surveyName}\" internal survey " .
317                            "depends on a question that does not exist prior to itself."
318                        );
319                    } elseif ( $dependencyName === $name ) {
320                        throw new InvalidArgumentException(
321                            "Question at index \"{$key}\" in the \"{$surveyName}\" internal survey " .
322                            "is referencing itself as a question it depends on."
323                        );
324                    }
325
326                    $referencedQuestion = $questionsByName[$dependencyName];
327                    $referencedAnswers = array_map(
328                        static fn ( array $answer ): string => $answer['label'],
329                        $referencedQuestion['answers']
330                    );
331
332                    foreach ( $answerIsOneOf as $answer ) {
333                        if ( !in_array( $answer, $referencedAnswers ) ) {
334                            throw new InvalidArgumentException(
335                                "Question at index \"{$key}\" in the \"{$surveyName}\" internal survey " .
336                                "depends on an answer that doesn't exist on the referenced question."
337                            );
338                        }
339                    }
340                }
341            }
342        }
343    }
344
345    /**
346     * @param array $spec
347     * @throws InvalidArgumentException
348     * @return ExternalSurvey
349     */
350    private function factoryExternal( array $spec ): ExternalSurvey {
351        $name = $spec['name'];
352        $questions = $spec['questions'] ?? [];
353
354        // Deprecated fields.
355        $question = $spec['question'] ?? null;
356        $description = $spec['description'] ?? null;
357        $link = $spec['link'] ?? null;
358        $privacyPolicy = $spec['privacyPolicy'] ?? null;
359        $yesMsg = $spec['yesMsg'] ?? null;
360        $noMsg = $spec['noMsg'] ?? null;
361        $instanceTokenParameterName = $spec['instanceTokenParameterName'] ?? null;
362
363        if ( !$link && !$questions ) {
364            throw new InvalidArgumentException(
365                "The \"{$name}\" external survey doesn't have a link."
366            );
367        }
368
369        if ( !$privacyPolicy ) {
370            throw new InvalidArgumentException(
371                "The \"{$name}\" external survey doesn't have a privacy policy."
372            );
373        }
374
375        $surveyQuestions = [];
376        if ( $questions ) {
377            foreach ( $questions as $surveyQuestion ) {
378                $surveyQuestions[] = new SurveyQuestion( $surveyQuestion, 'external' );
379            }
380        }
381
382        // Backwards compatibility: Map the deprecated field values to newer
383        // 'questions' array if they're defined and 'questions' is!
384        if ( !$surveyQuestions && $question ) {
385            $surveyQuestions[] = new SurveyQuestion( [
386                'name' => 'question-1',
387                'question' => $question,
388                'description' => $description,
389                'link' => $link,
390                // Set defaults for yes and no messages for compatibility.
391                'yesMsg' => $yesMsg ?? 'ext-quicksurveys-external-survey-yes-button',
392                'noMsg' => $noMsg ?? 'ext-quicksurveys-external-survey-no-button',
393                'instanceTokenParameterName' => $instanceTokenParameterName,
394            ], 'external' );
395        }
396
397        $survey = new ExternalSurvey(
398            $name,
399            $spec['coverage'],
400            $spec['platforms'],
401            $spec['privacyPolicy'],
402            $spec['additionalInfo'] ?? null,
403            $spec['confirmMsg'] ?? null,
404            new SurveyAudience( $spec['audience'] ?? [] ),
405            $surveyQuestions,
406            $question,
407            $description,
408            $spec['confirmDescription'] ?? null,
409            $link,
410            $instanceTokenParameterName,
411            $yesMsg,
412            $noMsg
413        );
414        $this->validateExternalSurveyQuestions( $survey->toArray() );
415
416        return $survey;
417    }
418
419    /**
420     * @param array $spec
421     * @throws InvalidArgumentException
422     * @return InternalSurvey
423     */
424    private function factoryInternal( array $spec ): InternalSurvey {
425        $name = $spec['name'];
426        $questions = $spec['questions'] ?? [];
427
428        // Deprecated fields.
429        $question = $spec['question'] ?? null;
430        $answers = $spec['answers'] ?? null;
431        $description = $spec['description'] ?? null;
432        $layout = $spec['layout'] ?? null;
433        $shuffleAnswersDisplay = $spec['shuffleAnswersDisplay'] ?? null;
434        $freeformTextLabel = $spec['freeformTextLabel'] ?? null;
435
436        if ( !$questions && !$answers ) {
437            throw new InvalidArgumentException(
438                "The \"{$name}\" internal survey doesn't have any answers."
439            );
440        }
441
442        $surveyQuestions = [];
443        if ( $questions ) {
444            foreach ( $questions as $surveyQuestion ) {
445                $surveyQuestions[] = new SurveyQuestion( $surveyQuestion, 'internal' );
446            }
447        } else {
448            // Only make the deprecated top-level layout field required if the
449            // corresponding deprecated question field is in use.
450            if ( !in_array( $layout, [ 'single-answer', 'multiple-answer' ] ) ) {
451                throw new InvalidArgumentException(
452                    "The \"{$name}\" internal survey layout is not one of \"single-answer\" or " .
453                    "\"multiple-answer\"."
454                );
455            }
456        }
457
458        // Backwards compatibility: Map the deprecated field values to newer
459        // 'questions' array if they're defined and 'questions' is empty.
460        if ( !$surveyQuestions && $question && $answers ) {
461            $surveyQuestions[] = new SurveyQuestion( [
462                'name' => 'question-1',
463                'layout' => $layout,
464                'question' => $question,
465                'description' => $description,
466                'shuffleAnswersDisplay' => $shuffleAnswersDisplay,
467                'answers' => array_map( static fn ( string $answer ): array => [
468                    'label' => $answer,
469                    'freeformTextLabel' => $freeformTextLabel,
470                ], $answers ),
471            ], 'internal' );
472        }
473
474        $survey = new InternalSurvey(
475            $name,
476            $spec['coverage'],
477            $spec['platforms'],
478            $spec['privacyPolicy'] ?? null,
479            $spec['additionalInfo'] ?? null,
480            $spec['confirmMsg'] ?? null,
481            new SurveyAudience( $spec['audience'] ?? [] ),
482            $surveyQuestions,
483            $question,
484            $description,
485            $spec['confirmDescription'] ?? null,
486            $answers,
487            $shuffleAnswersDisplay,
488            $freeformTextLabel,
489            $spec['embedElementId'] ?? null,
490            $layout
491        );
492        $this->validateInternalSurveyQuestions( $survey->toArray() );
493
494        return $survey;
495    }
496}