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