Code Coverage
 
Lines
Functions and Methods
Classes and Traits
Total
90.22% covered (success)
90.22%
166 / 184
88.37% covered (warning)
88.37%
38 / 43
CRAP
0.00% covered (danger)
0.00%
0 / 1
ValidationService
90.22% covered (success)
90.22%
166 / 184
88.37% covered (warning)
88.37%
38 / 43
172.35
0.00% covered (danger)
0.00%
0 / 1
 setLanguageContext
100.00% covered (success)
100.00%
1 / 1
100.00% covered (success)
100.00%
1 / 1
1
 getError
100.00% covered (success)
100.00%
1 / 1
100.00% covered (success)
100.00%
1 / 1
1
 validateServerGetRequest
100.00% covered (success)
100.00%
5 / 5
100.00% covered (success)
100.00%
1 / 1
3
 validateServerPostRequest
85.71% covered (warning)
85.71%
6 / 7
0.00% covered (danger)
0.00%
0 / 1
4.05
 validateServiceLocationCombination
84.21% covered (warning)
84.21%
16 / 19
0.00% covered (danger)
0.00%
0 / 1
9.32
 validateCaptcha
21.43% covered (danger)
21.43%
3 / 14
0.00% covered (danger)
0.00%
0 / 1
30.77
 validateGetBookableFreeDays
88.89% covered (warning)
88.89%
16 / 18
0.00% covered (danger)
0.00%
0 / 1
14.27
 validateGetProcessById
100.00% covered (success)
100.00%
6 / 6
100.00% covered (success)
100.00%
1 / 1
3
 validateGetAvailableAppointments
100.00% covered (success)
100.00%
11 / 11
100.00% covered (success)
100.00%
1 / 1
6
 validatePostAppointmentReserve
100.00% covered (success)
100.00%
11 / 11
100.00% covered (success)
100.00%
1 / 1
5
 validateAppointmentUpdateFields
100.00% covered (success)
100.00%
7 / 7
100.00% covered (success)
100.00%
1 / 1
1
 validateFamilyNameField
100.00% covered (success)
100.00%
2 / 2
100.00% covered (success)
100.00%
1 / 1
2
 validateEmailField
100.00% covered (success)
100.00%
2 / 2
100.00% covered (success)
100.00%
1 / 1
5
 validateTelephoneField
100.00% covered (success)
100.00%
5 / 5
100.00% covered (success)
100.00%
1 / 1
9
 validateCustomTextField
100.00% covered (success)
100.00%
5 / 5
100.00% covered (success)
100.00%
1 / 1
8
 validateGetScopeById
100.00% covered (success)
100.00%
3 / 3
100.00% covered (success)
100.00%
1 / 1
2
 validateGetServicesByOfficeId
100.00% covered (success)
100.00%
3 / 3
100.00% covered (success)
100.00%
1 / 1
2
 validateGetOfficeListByServiceId
100.00% covered (success)
100.00%
3 / 3
100.00% covered (success)
100.00%
1 / 1
2
 validateGetProcessFreeSlots
66.67% covered (warning)
66.67%
2 / 3
0.00% covered (danger)
0.00%
0 / 1
3.33
 validateGetProcessByIdTimestamps
100.00% covered (success)
100.00%
3 / 3
100.00% covered (success)
100.00%
1 / 1
2
 validateGetProcessNotFound
100.00% covered (success)
100.00%
3 / 3
100.00% covered (success)
100.00%
1 / 1
2
 validateScopesNotFound
100.00% covered (success)
100.00%
3 / 3
100.00% covered (success)
100.00%
1 / 1
4
 validateServicesNotFound
100.00% covered (success)
100.00%
3 / 3
100.00% covered (success)
100.00%
1 / 1
2
 validateOfficesNotFound
100.00% covered (success)
100.00%
3 / 3
100.00% covered (success)
100.00%
1 / 1
2
 validateAppointmentDaysNotFound
100.00% covered (success)
100.00%
3 / 3
100.00% covered (success)
100.00%
1 / 1
2
 validatenoAppointmentForThisScope
100.00% covered (success)
100.00%
1 / 1
100.00% covered (success)
100.00%
1 / 1
1
 validateServiceArrays
100.00% covered (success)
100.00%
14 / 14
100.00% covered (success)
100.00%
1 / 1
9
 isValidDate
100.00% covered (success)
100.00%
2 / 2
100.00% covered (success)
100.00%
1 / 1
2
 isDateRangeValid
100.00% covered (success)
100.00%
4 / 4
100.00% covered (success)
100.00%
1 / 1
1
 isValidNumericArray
100.00% covered (success)
100.00%
1 / 1
100.00% covered (success)
100.00%
1 / 1
2
 isValidOfficeIds
100.00% covered (success)
100.00%
1 / 1
100.00% covered (success)
100.00%
1 / 1
2
 isValidScopeId
100.00% covered (success)
100.00%
1 / 1
100.00% covered (success)
100.00%
1 / 1
2
 isValidProcessId
100.00% covered (success)
100.00%
1 / 1
100.00% covered (success)
100.00%
1 / 1
2
 isValidAuthKey
100.00% covered (success)
100.00%
1 / 1
100.00% covered (success)
100.00%
1 / 1
3
 isValidServiceIds
100.00% covered (success)
100.00%
1 / 1
100.00% covered (success)
100.00%
1 / 1
2
 isValidServiceCounts
100.00% covered (success)
100.00%
6 / 6
100.00% covered (success)
100.00%
1 / 1
7
 isValidTimestamp
100.00% covered (success)
100.00%
1 / 1
100.00% covered (success)
100.00%
1 / 1
3
 isValidEmail
100.00% covered (success)
100.00%
1 / 1
100.00% covered (success)
100.00%
1 / 1
2
 isValidTelephone
100.00% covered (success)
100.00%
1 / 1
100.00% covered (success)
100.00%
1 / 1
2
 isValidFamilyName
100.00% covered (success)
100.00%
1 / 1
100.00% covered (success)
100.00%
1 / 1
3
 isValidCustomTextfield
100.00% covered (success)
100.00%
1 / 1
100.00% covered (success)
100.00%
1 / 1
3
 isValidOfficeId
100.00% covered (success)
100.00%
1 / 1
100.00% covered (success)
100.00%
1 / 1
2
 isValidServiceId
100.00% covered (success)
100.00%
1 / 1
100.00% covered (success)
100.00%
1 / 1
2
1<?php
2
3declare(strict_types=1);
4
5namespace BO\Zmscitizenapi\Services\Core;
6
7use BO\Zmscitizenapi\Utils\ErrorMessages;
8use BO\Zmscitizenapi\Models\ThinnedScope;
9use BO\Zmscitizenapi\Services\Core\ZmsApiFacadeService;
10use BO\Zmscitizenapi\Services\Captcha\TokenValidationService;
11use BO\Zmsentities\Process;
12use BO\Zmsentities\Collection\ProcessList;
13use BO\Zmsentities\Collection\ScopeList;
14use DateTime;
15use Psr\Http\Message\ServerRequestInterface;
16
17/**
18 * @SuppressWarnings(PHPMD.ExcessiveClassComplexity)
19 * @TODO: Split this service into domain-specific validation services
20 */
21class ValidationService
22{
23    private static ?string $currentLanguage = null;
24    private const DATE_FORMAT = 'Y-m-d';
25    private const MIN_PROCESS_ID = 1;
26    private const PHONE_PATTERN = '/^\+?[0-9]\d{6,14}$/';
27    private const SERVICE_COUNT_PATTERN = '/^\d+$/';
28    private const EMAIL_PATTERN = '/^(?!.*\.\.)(?!\.)(?!.*\.$)[^\s@+]+(?<!\.)@(?!\.)[^\s@+]+\.[^\s@]{2,}$/';
29    private const MAX_FUTURE_DAYS = 365;
30    // Maximum days in the future for appointments
31
32    public static function setLanguageContext(?string $language): void
33    {
34        self::$currentLanguage = $language;
35    }
36
37    private static function getError(string $key): array
38    {
39        return ErrorMessages::get($key, self::$currentLanguage);
40    }
41
42    public static function validateServerGetRequest(?ServerRequestInterface $request): array
43    {
44        if (!$request instanceof ServerRequestInterface) {
45            return ['errors' => [self::getError('invalidRequest')]];
46        }
47
48        if ($request->getMethod() !== "GET") {
49            return ['errors' => [self::getError('invalidRequest')]];
50        }
51
52        return [];
53    }
54
55    public static function validateServerPostRequest(?ServerRequestInterface $request): array
56    {
57        if (!$request instanceof ServerRequestInterface) {
58            return ['errors' => [self::getError('invalidRequest')]];
59        }
60
61        if ($request->getMethod() !== "POST") {
62            return ['errors' => [self::getError('invalidRequest')]];
63        }
64
65        if ($request->getParsedBody() === null) {
66            return ['errors' => [self::getError('invalidRequest')]];
67        }
68
69        return [];
70    }
71
72    public static function validateServiceLocationCombination(int $officeId, array $serviceIds): array
73    {
74        static $officeServicesCache = [];
75
76        if ($officeId <= 0) {
77            return ['errors' => [self::getError('invalidOfficeId')]];
78        }
79
80        if (empty($serviceIds) || !self::isValidNumericArray($serviceIds)) {
81            return ['errors' => [self::getError('invalidServiceId')]];
82        }
83
84        if (!isset($officeServicesCache[$officeId])) {
85            $serviceList = ZmsApiFacadeService::getServicesByOfficeId($officeId);
86            $ids = [];
87            if (is_array($serviceList) && isset($serviceList['errors'])) {
88                $officeServicesCache[$officeId] = [];
89            } else {
90                foreach ($serviceList->services as $service) {
91                    $ids[] = (string)$service->id;
92                }
93                $officeServicesCache[$officeId] = $ids;
94            }
95        }
96        $availableServiceIds = $officeServicesCache[$officeId];
97
98        $serviceIdsStr = array_map('strval', $serviceIds);
99        $invalidServiceIds = array_diff($serviceIdsStr, $availableServiceIds);
100        return empty($invalidServiceIds)
101            ? []
102            : ['errors' => [self::getError('invalidLocationAndServiceCombination')]];
103    }
104
105    private static function validateCaptcha(bool $captchaRequired, ?string $captchaToken, ?TokenValidationService $tokenValidator): array
106    {
107        $errors = [];
108
109        if ($captchaRequired) {
110            if (!$tokenValidator) {
111                $status = TokenValidationService::TOKEN_MISSING;
112            } else {
113                $status = $tokenValidator->validateCaptchaToken($captchaToken);
114            }
115
116            if ($status !== TokenValidationService::TOKEN_VALID) {
117                switch ($status) {
118                    case TokenValidationService::TOKEN_MISSING:
119                        $errors[] = self::getError('captchaMissing');
120                        break;
121                    case TokenValidationService::TOKEN_EXPIRED:
122                        $errors[] = self::getError('captchaExpired');
123                        break;
124                    default:
125                        $errors[] = self::getError('captchaInvalid');
126                }
127            }
128        }
129
130        return $errors;
131    }
132
133    /**
134     * @SuppressWarnings(PHPMD.NPathComplexity)
135     * @TODO: Extract validation rules into separate rule objects using the specification pattern
136     */
137    public static function validateGetBookableFreeDays(?array $officeIds, ?array $serviceIds, ?string $startDate, ?string $endDate, ?array $serviceCounts, ?bool $captchaRequired = false, ?string $captchaToken = null, ?TokenValidationService $tokenValidator = null): array
138    {
139        $errors = [];
140        if (!self::isValidOfficeIds($officeIds)) {
141            $errors[] = self::getError('invalidOfficeId');
142        }
143
144        if (!self::isValidServiceIds($serviceIds)) {
145            $errors[] = self::getError('invalidServiceId');
146        }
147
148        if (!$startDate || !self::isValidDate($startDate)) {
149            $errors[] = self::getError('invalidStartDate');
150        }
151
152        if (!$endDate || !self::isValidDate($endDate)) {
153            $errors[] = self::getError('invalidEndDate');
154        }
155
156        if ($startDate && $endDate && self::isValidDate($startDate) && self::isValidDate($endDate)) {
157            if (new DateTime($startDate) > new DateTime($endDate)) {
158                $errors[] = self::getError('startDateAfterEndDate');
159            }
160
161            if (!self::isDateRangeValid($startDate, $endDate)) {
162                $errors[] = self::getError('dateRangeTooLarge');
163            }
164        }
165
166        if (!self::isValidServiceCounts($serviceCounts)) {
167            $errors[] = self::getError('invalidServiceCount');
168        }
169
170        $errors = array_merge($errors, self::validateCaptcha($captchaRequired, $captchaToken, $tokenValidator));
171
172        return ['errors' => $errors];
173    }
174
175    public static function validateGetProcessById(?int $processId, ?string $authKey): array
176    {
177        $errors = [];
178        if (!self::isValidProcessId($processId)) {
179            $errors[] = self::getError('invalidProcessId');
180        }
181
182        if (!self::isValidAuthKey($authKey)) {
183            $errors[] = self::getError('invalidAuthKey');
184        }
185
186        return ['errors' => $errors];
187    }
188
189    public static function validateGetAvailableAppointments(?string $date, ?array $officeIds, ?array $serviceIds, ?array $serviceCounts, ?bool $captchaRequired = false, ?string $captchaToken = null, ?TokenValidationService $tokenValidator = null): array
190    {
191        $errors = [];
192        if (!$date || !self::isValidDate($date)) {
193            $errors[] = self::getError('invalidDate');
194        }
195
196        if (!self::isValidOfficeIds($officeIds)) {
197            $errors[] = self::getError('invalidOfficeId');
198        }
199
200        if (!self::isValidServiceIds($serviceIds)) {
201            $errors[] = self::getError('invalidServiceId');
202        }
203
204        if (!self::isValidServiceCounts($serviceCounts)) {
205            $errors[] = self::getError('invalidServiceCount');
206        }
207
208        $errors = array_merge($errors, self::validateCaptcha($captchaRequired, $captchaToken, $tokenValidator));
209
210        return ['errors' => $errors];
211    }
212
213    public static function validatePostAppointmentReserve(?int $officeId, ?array $serviceIds, ?array $serviceCounts, ?int $timestamp, ?bool $captchaRequired = false, ?string $captchaToken = null, ?TokenValidationService $tokenValidator = null): array
214    {
215        $errors = [];
216        if (!self::isValidOfficeId($officeId)) {
217            $errors[] = self::getError('invalidOfficeId');
218        }
219
220        if (!self::isValidServiceIds($serviceIds)) {
221            $errors[] = self::getError('invalidServiceId');
222        }
223
224        if (!self::isValidTimestamp($timestamp)) {
225            $errors[] = self::getError('invalidTimestamp');
226        }
227
228        if (!self::isValidServiceCounts($serviceCounts)) {
229            $errors[] = self::getError('invalidServiceCount');
230        }
231
232        $errors = array_merge($errors, self::validateCaptcha($captchaRequired, $captchaToken, $tokenValidator));
233
234        return ['errors' => $errors];
235    }
236
237    public static function validateAppointmentUpdateFields(
238        ?string $familyName,
239        ?string $email,
240        ?string $telephone,
241        ?string $customTextfield,
242        ?string $customTextfield2,
243        ?ThinnedScope $scope
244    ): array {
245        $errors = [];
246
247        self::validateFamilyNameField($familyName, $errors);
248        self::validateEmailField($email, $scope, $errors);
249        self::validateTelephoneField($telephone, $scope, $errors);
250        self::validateCustomTextField($customTextfield, $scope?->customTextfieldActivated, $scope?->customTextfieldRequired, 'invalidCustomTextfield', $errors);
251        self::validateCustomTextField($customTextfield2, $scope?->customTextfield2Activated, $scope?->customTextfield2Required, 'invalidCustomTextfield2', $errors);
252
253        return ['errors' => $errors];
254    }
255
256    private static function validateFamilyNameField(?string $familyName, array &$errors): void
257    {
258        if (!self::isValidFamilyName($familyName)) {
259            $errors[] = self::getError('invalidFamilyName');
260        }
261    }
262
263    private static function validateEmailField(?string $email, ?ThinnedScope $scope, array &$errors): void
264    {
265        if ($scope && $scope->emailRequired && ($email === "" || !self::isValidEmail($email))) {
266            $errors[] = self::getError('invalidEmail');
267        }
268    }
269
270    private static function validateTelephoneField(?string $telephone, ?ThinnedScope $scope, array &$errors): void
271    {
272        if (!$scope || !$scope->telephoneActivated) {
273            return;
274        }
275
276        if (
277            ($scope->telephoneRequired && ($telephone === "" || !self::isValidTelephone($telephone))) ||
278            ($telephone !== null && $telephone !== "" && !self::isValidTelephone($telephone))
279        ) {
280            $errors[] = self::getError('invalidTelephone');
281        }
282    }
283
284    private static function validateCustomTextField(?string $fieldValue, ?bool $fieldActivated, ?bool $fieldRequired, string $errorKey, array &$errors): void
285    {
286        if (!$fieldActivated) {
287            return;
288        }
289
290        if (
291            ($fieldRequired && ($fieldValue === "" || !self::isValidCustomTextfield($fieldValue))) ||
292            ($fieldValue !== null && $fieldValue !== "" && !self::isValidCustomTextfield($fieldValue))
293        ) {
294            $errors[] = self::getError($errorKey);
295        }
296    }
297
298    public static function validateGetScopeById(?int $scopeId): array
299    {
300        return !self::isValidScopeId($scopeId)
301            ? ['errors' => [self::getError('invalidScopeId')]]
302            : [];
303    }
304
305    public static function validateGetServicesByOfficeId(?int $officeId): array
306    {
307        return !self::isValidOfficeId($officeId)
308            ? ['errors' => [self::getError('invalidOfficeId')]]
309            : [];
310    }
311
312    public static function validateGetOfficeListByServiceId(?int $serviceId): array
313    {
314        return !self::isValidServiceId($serviceId)
315            ? ['errors' => [self::getError('invalidServiceId')]]
316            : [];
317    }
318
319    public static function validateGetProcessFreeSlots(?ProcessList $freeSlots): array
320    {
321        return empty($freeSlots) || !is_iterable($freeSlots)
322            ? ['errors' => [self::getError('appointmentNotAvailable')]]
323            : [];
324    }
325
326    public static function validateGetProcessByIdTimestamps(?array $appointmentTimestamps): array
327    {
328        return empty($appointmentTimestamps)
329            ? ['errors' => [self::getError('appointmentNotAvailable')]]
330            : [];
331    }
332
333    public static function validateGetProcessNotFound(?Process $process): array
334    {
335        return !$process
336            ? ['errors' => [self::getError('appointmentNotAvailable')]]
337            : [];
338    }
339
340    public static function validateScopesNotFound(?ScopeList $scopes): array
341    {
342        return empty($scopes) || $scopes === null || $scopes->count() === 0
343            ? ['errors' => [self::getError('scopesNotFound')]]
344            : [];
345    }
346
347    public static function validateServicesNotFound(?array $services): array
348    {
349        return empty($services)
350            ? ['errors' => [self::getError('requestNotFound')]]
351            : [];
352    }
353
354    public static function validateOfficesNotFound(?array $offices): array
355    {
356        return empty($offices)
357            ? ['errors' => [self::getError('providerNotFound')]]
358            : [];
359    }
360
361    public static function validateAppointmentDaysNotFound(?array $formattedDays): array
362    {
363        return empty($formattedDays)
364            ? ['errors' => [self::getError('noAppointmentForThisDay')]]
365            : [];
366    }
367
368    public static function validatenoAppointmentForThisScope(): array
369    {
370        return ['errors' => [self::getError('noAppointmentForThisScope')]];
371    }
372
373    public static function validateServiceArrays(array $serviceIds, array $serviceCounts): array
374    {
375        $errors = [];
376        if (empty($serviceIds) || empty($serviceCounts)) {
377            $errors[] = self::getError('emptyServiceArrays');
378        }
379
380        if (count($serviceIds) !== count($serviceCounts)) {
381            $errors[] = self::getError('mismatchedArrays');
382        }
383
384        foreach ($serviceIds as $id) {
385            if (!is_numeric($id)) {
386                $errors[] = self::getError('invalidServiceId');
387                break;
388            }
389        }
390
391        foreach ($serviceCounts as $count) {
392            if (!is_numeric($count) || $count < 0) {
393                $errors[] = self::getError('invalidServiceCount');
394                break;
395            }
396        }
397
398        return $errors;
399    }
400
401    /*  Helper methods for validation */
402    private static function isValidDate(string $date): bool
403    {
404        $dateTime = DateTime::createFromFormat(self::DATE_FORMAT, $date);
405        return $dateTime && $dateTime->format(self::DATE_FORMAT) === $date;
406    }
407
408    private static function isDateRangeValid(string $startDate, string $endDate): bool
409    {
410        $start = new DateTime($startDate);
411        $end = new DateTime($endDate);
412        $diff = $start->diff($end);
413        return $diff->days <= self::MAX_FUTURE_DAYS;
414    }
415
416    private static function isValidNumericArray(array $array): bool
417    {
418        return !empty($array) && array_filter($array, 'is_numeric') === $array;
419    }
420
421    private static function isValidOfficeIds(?array $officeIds): bool
422    {
423        return !empty($officeIds) && self::isValidNumericArray($officeIds);
424    }
425
426    private static function isValidScopeId(?int $scopeId): bool
427    {
428        return !empty($scopeId) && $scopeId > 0;
429    }
430
431    private static function isValidProcessId(?int $processId): bool
432    {
433        return !empty($processId) && $processId >= self::MIN_PROCESS_ID;
434    }
435
436    private static function isValidAuthKey(?string $authKey): bool
437    {
438        return !empty($authKey) && is_string($authKey) && strlen(trim($authKey)) > 0;
439    }
440
441    private static function isValidServiceIds(?array $serviceIds): bool
442    {
443        return !empty($serviceIds) && self::isValidNumericArray($serviceIds);
444    }
445
446    private static function isValidServiceCounts(?array $serviceCounts): bool
447    {
448        if (empty($serviceCounts) || !is_array($serviceCounts)) {
449            return false;
450        }
451
452        foreach ($serviceCounts as $count) {
453            if (!is_numeric($count) || $count < 0 || !preg_match(self::SERVICE_COUNT_PATTERN, (string) $count)) {
454                return false;
455            }
456        }
457
458        return true;
459    }
460
461    private static function isValidTimestamp(?int $timestamp): bool
462    {
463        return !empty($timestamp) && is_numeric($timestamp) && $timestamp > time();
464    }
465
466    private static function isValidEmail(?string $email): bool
467    {
468        return !empty($email) && preg_match(self::EMAIL_PATTERN, $email) === 1;
469    }
470
471    private static function isValidTelephone(?string $telephone): bool
472    {
473        return $telephone === null || preg_match(self::PHONE_PATTERN, $telephone);
474    }
475
476    private static function isValidFamilyName(?string $familyName): bool
477    {
478        return !empty($familyName) && is_string($familyName) && strlen(trim($familyName)) > 0;
479    }
480
481    private static function isValidCustomTextfield(?string $customTextfield): bool
482    {
483        return $customTextfield === null || (is_string($customTextfield) && strlen(trim($customTextfield)) > 0);
484    }
485
486    private static function isValidOfficeId(?int $officeId): bool
487    {
488        return !empty($officeId) && $officeId > 0;
489    }
490
491    private static function isValidServiceId(?int $serviceId): bool
492    {
493        return !empty($serviceId) && $serviceId > 0;
494    }
495}