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 | 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 | } |