Code Coverage
 
Lines
Functions and Methods
Classes and Traits
Total
99.19% covered (success)
99.19%
123 / 124
88.89% covered (warning)
88.89%
8 / 9
CRAP
0.00% covered (danger)
0.00%
0 / 1
SurveyFactory
99.19% covered (success)
99.19%
123 / 124
88.89% covered (warning)
88.89%
8 / 9
39
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
 arrayIsList
100.00% covered (success)
100.00%
2 / 2
100.00% covered (success)
100.00%
1 / 1
1
 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%
8 / 8
100.00% covered (success)
100.00%
1 / 1
3
 validateSpec
100.00% covered (success)
100.00%
19 / 19
100.00% covered (success)
100.00%
1 / 1
10
 validatePlatforms
100.00% covered (success)
100.00%
18 / 18
100.00% covered (success)
100.00%
1 / 1
5
 factoryExternal
100.00% covered (success)
100.00%
22 / 22
100.00% covered (success)
100.00%
1 / 1
3
 factoryInternal
100.00% covered (success)
100.00%
27 / 27
100.00% covered (success)
100.00%
1 / 1
3
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 ( !$this->arrayIsList( $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     * Gets whether the array is a list, i.e. an integer-indexed array with indices starting at 0.
59     *
60     * As written, this method trades performance for elegance. This method should not be called on
61     * large arrays.
62     *
63     * TODO: Replace this with array_is_list when MediaWiki supports PHP >= 8.1
64     *
65     * @param array $array
66     * @return bool
67     */
68    private function arrayIsList( array $array ): bool {
69        $array = array_keys( $array );
70
71        return $array === array_keys( $array );
72    }
73
74    /**
75     * checks QuickSurveys name for duplications
76     *
77     * @param array $spec
78     * @param array[] $specs
79     * @return bool
80     */
81    private function validateUniqueName( array $spec, array $specs ): bool {
82        if ( !isset( $spec[ 'name' ] ) ) {
83            $this->logger->error( "Bad survey configuration: The survey name does not have a value",
84                        [ 'exception' => "Bad survey configuration: The survey name does not have a value" ] );
85            return false;
86        }
87        if ( count( $specs ) < 2 ) {
88            return true;
89        }
90
91        $name = trim( $spec[ 'name' ] );
92        $numberDuplicates = 0;
93
94        foreach ( $specs as $specArray ) {
95            // if there is more than one copy of the item, it is a duplicate, enter log message
96            if ( ( $specArray['enabled'] ?? false ) &&
97                strcasecmp( trim( $specArray['name'] ?? '' ), $name ) === 0 &&
98                $numberDuplicates++
99            ) {
100                // write out to logger
101                $this->logger->error( "Bad survey configuration: The survey name \"{$name}\" is not unique",
102                                    [ 'exception' => "The \"{$name}\" survey name is not unique" ] );
103                return false;
104            }
105        }
106
107        return true;
108    }
109
110    /**
111     * Creates an instance of either the InternalSurvey or ExternalSurvey class
112     * given a specification.
113     *
114     * An exception is thrown if any of the following conditions aren't met:
115     *
116     * <ul>
117     *   <li>A survey must have a question</li>
118     *   <li>A survey must have a description</li>
119     *   <li>A survey's type must be either "internal" or "external"</li>
120     *   <li>A survey must have a coverage</li>
121     *   <li>An internal survey must have a set of questions</li>
122     *   <li>An external survey must have a privacy policy</li>
123     *   <li>An internal survey must have a layout of either "single-answer" or "multiple-answer"</li>
124     * </ul>
125     *
126     * @param array $spec
127     * @return Survey|null
128     */
129    public function newSurvey( array $spec ): ?Survey {
130        try {
131            $this->validateSpec( $spec );
132
133            $survey = $spec['type'] === 'internal'
134                ? $this->factoryInternal( $spec )
135                : $this->factoryExternal( $spec );
136
137            return $survey;
138        } catch ( InvalidArgumentException $ex ) {
139            $this->logger->error( "Bad survey configuration: " . $ex->getMessage(), [ 'exception' => $ex ] );
140            return null;
141        }
142    }
143
144    /**
145     * @param array $spec
146     * @throws InvalidArgumentException
147     */
148    private function validateSpec( array $spec ) {
149        $name = $spec['name'];
150
151        if ( !isset( $spec['question'] ) ) {
152            throw new InvalidArgumentException( "The \"{$name}\" survey doesn't have a question." );
153        }
154
155        if (
156            !isset( $spec['type'] )
157            || ( $spec['type'] !== 'internal' && $spec['type'] !== 'external' )
158        ) {
159            throw new InvalidArgumentException(
160                "The \"{$name}\" survey isn't marked as internal or external."
161            );
162        }
163
164        if ( !isset( $spec['coverage'] ) ) {
165            throw new InvalidArgumentException( "The \"{$name}\" survey doesn't have a coverage." );
166        }
167
168        if ( !isset( $spec['platforms'] ) ) {
169            throw new InvalidArgumentException( "The \"{$name}\" survey doesn't have any platforms." );
170        }
171
172        if ( $spec['type'] === 'external' && isset( $spec['link'] ) ) {
173            $link = $spec['link'];
174            $url = wfMessage( $link )->inContentLanguage()->plain();
175            $bit = parse_url( $url, PHP_URL_SCHEME );
176
177            if ( $bit !== 'https' ) {
178                throw new InvalidArgumentException( "The \"{$name}\" external survey must have a secure url." );
179            }
180        }
181
182        $this->validatePlatforms( $spec );
183    }
184
185    /**
186     * @param array $spec
187     * @throws InvalidArgumentException
188     */
189    private function validatePlatforms( array $spec ) {
190        foreach ( self::VALID_PLATFORM_MODES as $platform => $validModes ) {
191            if ( !isset( $spec['platforms'][$platform] ) ) {
192                continue;
193            }
194
195            $modes = $spec['platforms'][$platform];
196
197            if (
198                !is_array( $modes ) ||
199                array_diff(
200                    $modes,
201                    array_intersect(
202                        $validModes,
203                        $modes
204                    )
205                )
206            ) {
207                throw new InvalidArgumentException(
208                    "The \"{$spec['name']}\" survey has specified an invalid platform. " .
209                    "Please specify one or more of the following for the \"{$platform}\" platform: " .
210                    implode( ', ', $validModes ) .
211                    '.'
212                );
213            }
214        }
215    }
216
217    /**
218     * @param array $spec
219     * @throws InvalidArgumentException
220     * @return ExternalSurvey
221     */
222    private function factoryExternal( array $spec ): ExternalSurvey {
223        $name = $spec['name'];
224
225        if ( !isset( $spec['link'] ) ) {
226            throw new InvalidArgumentException( "The \"{$name}\" external survey doesn't have a link." );
227        }
228
229        if ( !isset( $spec['privacyPolicy'] ) ) {
230            throw new InvalidArgumentException(
231                "The \"{$name}\" external survey doesn't have a privacy policy."
232            );
233        }
234
235        return new ExternalSurvey(
236            $name,
237            $spec['question'],
238            $spec['description'] ?? null,
239            $spec['coverage'],
240            $spec['platforms'],
241            $spec['privacyPolicy'],
242            $spec['additionalInfo'] ?? null,
243            $spec['confirmMsg'] ?? null,
244            new SurveyAudience( $spec['audience'] ?? [] ),
245            $spec['link'],
246            $spec['instanceTokenParameterName'] ?? '',
247            $spec['yesMsg'] ?? null,
248            $spec['noMsg'] ?? null
249        );
250    }
251
252    /**
253     * @param array $spec
254     * @throws InvalidArgumentException
255     * @return InternalSurvey
256     */
257    private function factoryInternal( array $spec ): InternalSurvey {
258        $name = $spec['name'];
259
260        if ( !isset( $spec['answers'] ) ) {
261            throw new InvalidArgumentException(
262                "The \"{$name}\" internal survey doesn't have any answers."
263            );
264        }
265
266        // TODO: Remove default value after a deprecation period.  See T255130.
267        $layout = $spec['layout'] ?? 'single-answer';
268        if ( !in_array( $layout, [ 'single-answer', 'multiple-answer' ] ) ) {
269            throw new InvalidArgumentException(
270                "The \"{$name}\" internal survey layout is not one of \"single-answer\" or " .
271                "\"multiple-answer\"."
272            );
273        }
274
275        return new InternalSurvey(
276            $name,
277            $spec['question'],
278            $spec['description'] ?? null,
279            $spec['coverage'],
280            $spec['platforms'],
281            $spec['privacyPolicy'] ?? null,
282            $spec['additionalInfo'] ?? null,
283            $spec['confirmMsg'] ?? null,
284            new SurveyAudience( $spec['audience'] ?? [] ),
285            $spec['answers'],
286            $spec['shuffleAnswersDisplay'] ?? true,
287            $spec['freeformTextLabel'] ?? null,
288            $spec['embedElementId'] ?? null,
289            $layout
290        );
291    }
292}