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