Code Coverage |
||||||||||
Lines |
Functions and Methods |
Classes and Traits |
||||||||
Total | |
96.38% |
266 / 276 |
|
80.00% |
8 / 10 |
CRAP | |
0.00% |
0 / 1 |
SurveyFactory | |
96.38% |
266 / 276 |
|
80.00% |
8 / 10 |
77 | |
0.00% |
0 / 1 |
__construct | |
100.00% |
1 / 1 |
|
100.00% |
1 / 1 |
1 | |||
parseSurveyConfig | |
100.00% |
11 / 11 |
|
100.00% |
1 / 1 |
6 | |||
validateUniqueName | |
93.75% |
15 / 16 |
|
0.00% |
0 / 1 |
7.01 | |||
newSurvey | |
100.00% |
7 / 7 |
|
100.00% |
1 / 1 |
3 | |||
validateSpec | |
100.00% |
26 / 26 |
|
100.00% |
1 / 1 |
16 | |||
validatePlatforms | |
100.00% |
18 / 18 |
|
100.00% |
1 / 1 |
5 | |||
validateExternalSurveyQuestions | |
100.00% |
22 / 22 |
|
100.00% |
1 / 1 |
5 | |||
validateInternalSurveyQuestions | |
100.00% |
72 / 72 |
|
100.00% |
1 / 1 |
17 | |||
factoryExternal | |
82.00% |
41 / 50 |
|
0.00% |
0 / 1 |
8.37 | |||
factoryInternal | |
100.00% |
53 / 53 |
|
100.00% |
1 / 1 |
9 |
1 | <?php |
2 | |
3 | namespace QuickSurveys; |
4 | |
5 | use InvalidArgumentException; |
6 | use Psr\Log\LoggerInterface; |
7 | |
8 | class 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 | } |