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
166.05
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
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%
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 * @TODO: Split this service into domain-specific validation services
21 */
22class ValidationService
23{
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    private const AUTH_KEY_LEGACY_HEX_LENGTH = 4;
32    private const AUTH_KEY_NEW_HEX_LENGTH = 64;
33
34    private static function getError(string $key): array
35    {
36        return ErrorMessages::get($key);
37    }
38
39    public static function validateServerGetRequest(?ServerRequestInterface $request): array
40    {
41        if (!$request instanceof ServerRequestInterface) {
42            return ['errors' => [self::getError('invalidRequest')]];
43        }
44
45        if ($request->getMethod() !== "GET") {
46            return ['errors' => [self::getError('invalidRequest')]];
47        }
48
49        return [];
50    }
51
52    public static function validateServerPostRequest(?ServerRequestInterface $request): array
53    {
54        if (!$request instanceof ServerRequestInterface) {
55            return ['errors' => [self::getError('invalidRequest')]];
56        }
57
58        if ($request->getMethod() !== "POST") {
59            return ['errors' => [self::getError('invalidRequest')]];
60        }
61
62        if ($request->getParsedBody() === null) {
63            return ['errors' => [self::getError('invalidRequest')]];
64        }
65
66        return [];
67    }
68
69    public static function validateServiceLocationCombination(int $officeId, array $serviceIds, bool $showUnpublished = false): array
70    {
71        static $officeServicesCache = [];
72
73        if ($officeId <= 0) {
74            return ['errors' => [self::getError('invalidOfficeId')]];
75        }
76
77        if (empty($serviceIds) || !self::isValidNumericArray($serviceIds)) {
78            return ['errors' => [self::getError('invalidServiceId')]];
79        }
80
81        $cacheKey = $officeId . '|' . ($showUnpublished ? '1' : '0');
82        if (!isset($officeServicesCache[$cacheKey])) {
83            $serviceList = ZmsApiFacadeService::getServicesByOfficeId($officeId, $showUnpublished);
84            $ids = [];
85            if (is_array($serviceList) && isset($serviceList['errors'])) {
86                $officeServicesCache[$cacheKey] = [];
87            } else {
88                foreach ($serviceList->services as $service) {
89                    $ids[] = (string)$service->id;
90                }
91                $officeServicesCache[$cacheKey] = $ids;
92            }
93        }
94        $availableServiceIds = $officeServicesCache[$cacheKey];
95
96        $serviceIdsStr = array_map('strval', $serviceIds);
97        $invalidServiceIds = array_diff($serviceIdsStr, $availableServiceIds);
98        return empty($invalidServiceIds)
99            ? []
100            : ['errors' => [self::getError('invalidLocationAndServiceCombination')]];
101    }
102
103    private static function validateCaptcha(bool $captchaRequired, ?string $captchaToken, ?TokenValidationService $tokenValidator): array
104    {
105        $errors = [];
106
107        if ($captchaRequired) {
108            if (!$tokenValidator) {
109                $status = TokenValidationService::TOKEN_MISSING;
110            } else {
111                $status = $tokenValidator->validateCaptchaToken($captchaToken);
112            }
113
114            if ($status !== TokenValidationService::TOKEN_VALID) {
115                switch ($status) {
116                    case TokenValidationService::TOKEN_MISSING:
117                        $errors[] = self::getError('captchaMissing');
118                        break;
119                    case TokenValidationService::TOKEN_EXPIRED:
120                        $errors[] = self::getError('captchaExpired');
121                        break;
122                    default:
123                        $errors[] = self::getError('captchaInvalid');
124                }
125            }
126        }
127
128        return $errors;
129    }
130
131    /**
132     * @SuppressWarnings(PHPMD.NPathComplexity)
133     * @TODO: Extract validation rules into separate rule objects using the specification pattern
134     */
135    public static function validateGetBookableFreeDays(?array $officeIds, ?array $serviceIds, ?string $startDate, ?string $endDate, ?array $serviceCounts, ?bool $captchaRequired = false, ?string $captchaToken = null, ?TokenValidationService $tokenValidator = null): array
136    {
137        $errors = [];
138        if (!self::isValidOfficeIds($officeIds)) {
139            $errors[] = self::getError('invalidOfficeId');
140        }
141
142        if (!self::isValidServiceIds($serviceIds)) {
143            $errors[] = self::getError('invalidServiceId');
144        }
145
146        if (!$startDate || !self::isValidDate($startDate)) {
147            $errors[] = self::getError('invalidStartDate');
148        }
149
150        if (!$endDate || !self::isValidDate($endDate)) {
151            $errors[] = self::getError('invalidEndDate');
152        }
153
154        if ($startDate && $endDate && self::isValidDate($startDate) && self::isValidDate($endDate)) {
155            if (new DateTime($startDate) > new DateTime($endDate)) {
156                $errors[] = self::getError('startDateAfterEndDate');
157            }
158
159            if (!self::isDateRangeValid($startDate, $endDate)) {
160                $errors[] = self::getError('dateRangeTooLarge');
161            }
162        }
163
164        if (!self::isValidServiceCounts($serviceCounts)) {
165            $errors[] = self::getError('invalidServiceCount');
166        }
167
168        $errors = array_merge($errors, self::validateCaptcha($captchaRequired, $captchaToken, $tokenValidator));
169
170        return ['errors' => $errors];
171    }
172
173    public static function validateGetProcessById(?int $processId, ?string $authKey): array
174    {
175        $errors = [];
176        if (!self::isValidProcessId($processId)) {
177            $errors[] = self::getError('invalidProcessId');
178        }
179
180        if (!self::isValidAuthKey($authKey)) {
181            $errors[] = self::getError('invalidAuthKey');
182        }
183
184        return ['errors' => $errors];
185    }
186
187    public static function validateGetAvailableAppointments(?string $date, ?array $officeIds, ?array $serviceIds, ?array $serviceCounts, ?bool $captchaRequired = false, ?string $captchaToken = null, ?TokenValidationService $tokenValidator = null): array
188    {
189        $errors = [];
190        if (!$date || !self::isValidDate($date)) {
191            $errors[] = self::getError('invalidDate');
192        }
193
194        if (!self::isValidOfficeIds($officeIds)) {
195            $errors[] = self::getError('invalidOfficeId');
196        }
197
198        if (!self::isValidServiceIds($serviceIds)) {
199            $errors[] = self::getError('invalidServiceId');
200        }
201
202        if (!self::isValidServiceCounts($serviceCounts)) {
203            $errors[] = self::getError('invalidServiceCount');
204        }
205
206        $errors = array_merge($errors, self::validateCaptcha($captchaRequired, $captchaToken, $tokenValidator));
207
208        return ['errors' => $errors];
209    }
210
211    public static function validatePostAppointmentReserve(?int $officeId, ?array $serviceIds, ?array $serviceCounts, ?int $timestamp, ?bool $captchaRequired = false, ?string $captchaToken = null, ?TokenValidationService $tokenValidator = null): array
212    {
213        $errors = [];
214        if (!self::isValidOfficeId($officeId)) {
215            $errors[] = self::getError('invalidOfficeId');
216        }
217
218        if (!self::isValidServiceIds($serviceIds)) {
219            $errors[] = self::getError('invalidServiceId');
220        }
221
222        if (!self::isValidTimestamp($timestamp)) {
223            $errors[] = self::getError('invalidTimestamp');
224        }
225
226        if (!self::isValidServiceCounts($serviceCounts)) {
227            $errors[] = self::getError('invalidServiceCount');
228        }
229
230        $errors = array_merge($errors, self::validateCaptcha($captchaRequired, $captchaToken, $tokenValidator));
231
232        return ['errors' => $errors];
233    }
234
235    public static function validateAppointmentUpdateFields(
236        ?string $familyName,
237        ?string $email,
238        ?string $telephone,
239        ?string $customTextfield,
240        ?string $customTextfield2,
241        ?ThinnedScope $scope
242    ): array {
243        $errors = [];
244
245        self::validateFamilyNameField($familyName, $errors);
246        self::validateEmailField($email, $scope, $errors);
247        self::validateTelephoneField($telephone, $scope, $errors);
248        self::validateCustomTextField($customTextfield, $scope?->customTextfieldActivated, $scope?->customTextfieldRequired, 'invalidCustomTextfield', $errors);
249        self::validateCustomTextField($customTextfield2, $scope?->customTextfield2Activated, $scope?->customTextfield2Required, 'invalidCustomTextfield2', $errors);
250
251        return ['errors' => $errors];
252    }
253
254    private static function validateFamilyNameField(?string $familyName, array &$errors): void
255    {
256        if (!self::isValidFamilyName($familyName)) {
257            $errors[] = self::getError('invalidFamilyName');
258        }
259    }
260
261    private static function validateEmailField(?string $email, ?ThinnedScope $scope, array &$errors): void
262    {
263        if ($scope && $scope->emailRequired && ($email === "" || !self::isValidEmail($email))) {
264            $errors[] = self::getError('invalidEmail');
265        }
266    }
267
268    private static function validateTelephoneField(?string $telephone, ?ThinnedScope $scope, array &$errors): void
269    {
270        if (!$scope || !$scope->telephoneActivated) {
271            return;
272        }
273
274        if (
275            ($scope->telephoneRequired && ($telephone === "" || !self::isValidTelephone($telephone))) ||
276            ($telephone !== null && $telephone !== "" && !self::isValidTelephone($telephone))
277        ) {
278            $errors[] = self::getError('invalidTelephone');
279        }
280    }
281
282    private static function validateCustomTextField(?string $fieldValue, ?bool $fieldActivated, ?bool $fieldRequired, string $errorKey, array &$errors): void
283    {
284        if (!$fieldActivated) {
285            return;
286        }
287
288        $normalized = ProcessPlainText::normalize($fieldValue);
289        if ($fieldRequired && trim($normalized) === '') {
290            $errors[] = self::getError($errorKey);
291            return;
292        }
293        if ($fieldValue !== null && $fieldValue !== '' && mb_strlen($normalized, 'UTF-8') > ProcessPlainText::MAX_CUSTOM_TEXTFIELD_CHARS) {
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        if ($authKey === null) {
439            return false;
440        }
441        $authKey = trim($authKey);
442        $len = strlen($authKey);
443        if ($len !== self::AUTH_KEY_LEGACY_HEX_LENGTH && $len !== self::AUTH_KEY_NEW_HEX_LENGTH) {
444            return false;
445        }
446
447        return ctype_xdigit($authKey);
448    }
449
450    private static function isValidServiceIds(?array $serviceIds): bool
451    {
452        return !empty($serviceIds) && self::isValidNumericArray($serviceIds);
453    }
454
455    private static function isValidServiceCounts(?array $serviceCounts): bool
456    {
457        if (empty($serviceCounts) || !is_array($serviceCounts)) {
458            return false;
459        }
460
461        foreach ($serviceCounts as $count) {
462            if (!is_numeric($count) || $count < 0 || !preg_match(self::SERVICE_COUNT_PATTERN, (string) $count)) {
463                return false;
464            }
465        }
466
467        return true;
468    }
469
470    private static function isValidTimestamp(?int $timestamp): bool
471    {
472        return !empty($timestamp) && is_numeric($timestamp) && $timestamp > time();
473    }
474
475    private static function isValidEmail(?string $email): bool
476    {
477        return !empty($email) && preg_match(self::EMAIL_PATTERN, $email) === 1;
478    }
479
480    private static function isValidTelephone(?string $telephone): bool
481    {
482        return $telephone === null || preg_match(self::PHONE_PATTERN, $telephone);
483    }
484
485    private static function isValidFamilyName(?string $familyName): bool
486    {
487        return !empty($familyName) && is_string($familyName) && strlen(trim($familyName)) > 0;
488    }
489
490
491    private static function isValidOfficeId(?int $officeId): bool
492    {
493        return !empty($officeId) && $officeId > 0;
494    }
495
496    private static function isValidServiceId(?int $serviceId): bool
497    {
498        return !empty($serviceId) && $serviceId > 0;
499    }
500}