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