Code Coverage
 
Lines
Functions and Methods
Classes and Traits
Total
96.23% covered (success)
96.23%
153 / 159
90.48% covered (success)
90.48%
38 / 42
CRAP
0.00% covered (danger)
0.00%
0 / 1
ValidationService
96.23% covered (success)
96.23%
153 / 159
90.48% covered (success)
90.48%
38 / 42
137
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
 validateGetBookableFreeDays
88.24% covered (warning)
88.24%
15 / 17
0.00% covered (danger)
0.00%
0 / 1
14.32
 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%
10 / 10
100.00% covered (success)
100.00%
1 / 1
6
 validatePostAppointmentReserve
100.00% covered (success)
100.00%
10 / 10
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
4
 validateTelephoneField
100.00% covered (success)
100.00%
5 / 5
100.00% covered (success)
100.00%
1 / 1
7
 validateCustomTextField
100.00% covered (success)
100.00%
5 / 5
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%
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\Zmsentities\Process;
11use BO\Zmsentities\Collection\ProcessList;
12use BO\Zmsentities\Collection\ScopeList;
13use DateTime;
14use Psr\Http\Message\ServerRequestInterface;
15
16/**
17 * @SuppressWarnings(PHPMD.ExcessiveClassComplexity)
18 * @TODO: Split this service into domain-specific validation services
19 */
20class ValidationService
21{
22    private static ?string $currentLanguage = null;
23    private const DATE_FORMAT = 'Y-m-d';
24    private const MIN_PROCESS_ID = 1;
25    private const PHONE_PATTERN = '/^\+?[0-9]\d{6,14}$/';
26    private const SERVICE_COUNT_PATTERN = '/^\d+$/';
27    private const MAX_FUTURE_DAYS = 365;
28    // Maximum days in the future for appointments
29
30    public static function setLanguageContext(?string $language): void
31    {
32        self::$currentLanguage = $language;
33    }
34
35    private static function getError(string $key): array
36    {
37        return ErrorMessages::get($key, self::$currentLanguage);
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): array
71    {
72        if ($officeId <= 0) {
73            return ['errors' => [self::getError('invalidOfficeId')]];
74        }
75
76        if (empty($serviceIds) || !self::isValidNumericArray($serviceIds)) {
77            return ['errors' => [self::getError('invalidServiceId')]];
78        }
79
80        $availableServices = ZmsApiFacadeService::getServicesProvidedAtOffice($officeId);
81        $availableServiceIds = [];
82        foreach ($availableServices as $service) {
83            $availableServiceIds[] = $service->id;
84        }
85
86        $invalidServiceIds = array_diff($serviceIds, $availableServiceIds);
87        return empty($invalidServiceIds)
88            ? []
89            : ['errors' => [self::getError('invalidLocationAndServiceCombination')]];
90    }
91
92    /**
93     * @SuppressWarnings(PHPMD.NPathComplexity)
94     * @TODO: Extract validation rules into separate rule objects using the specification pattern
95     */
96    public static function validateGetBookableFreeDays(?array $officeIds, ?array $serviceIds, ?string $startDate, ?string $endDate, ?array $serviceCounts): array
97    {
98        $errors = [];
99        if (!self::isValidOfficeIds($officeIds)) {
100            $errors[] = self::getError('invalidOfficeId');
101        }
102
103        if (!self::isValidServiceIds($serviceIds)) {
104            $errors[] = self::getError('invalidServiceId');
105        }
106
107        if (!$startDate || !self::isValidDate($startDate)) {
108            $errors[] = self::getError('invalidStartDate');
109        }
110
111        if (!$endDate || !self::isValidDate($endDate)) {
112            $errors[] = self::getError('invalidEndDate');
113        }
114
115        if ($startDate && $endDate && self::isValidDate($startDate) && self::isValidDate($endDate)) {
116            if (new DateTime($startDate) > new DateTime($endDate)) {
117                $errors[] = self::getError('startDateAfterEndDate');
118            }
119
120            if (!self::isDateRangeValid($startDate, $endDate)) {
121                $errors[] = self::getError('dateRangeTooLarge');
122            }
123        }
124
125        if (!self::isValidServiceCounts($serviceCounts)) {
126            $errors[] = self::getError('invalidServiceCount');
127        }
128
129        return ['errors' => $errors];
130    }
131
132    public static function validateGetProcessById(?int $processId, ?string $authKey): array
133    {
134        $errors = [];
135        if (!self::isValidProcessId($processId)) {
136            $errors[] = self::getError('invalidProcessId');
137        }
138
139        if (!self::isValidAuthKey($authKey)) {
140            $errors[] = self::getError('invalidAuthKey');
141        }
142
143        return ['errors' => $errors];
144    }
145
146    public static function validateGetAvailableAppointments(?string $date, ?array $officeIds, ?array $serviceIds, ?array $serviceCounts): array
147    {
148        $errors = [];
149        if (!$date || !self::isValidDate($date)) {
150            $errors[] = self::getError('invalidDate');
151        }
152
153        if (!self::isValidOfficeIds($officeIds)) {
154            $errors[] = self::getError('invalidOfficeId');
155        }
156
157        if (!self::isValidServiceIds($serviceIds)) {
158            $errors[] = self::getError('invalidServiceId');
159        }
160
161        if (!self::isValidServiceCounts($serviceCounts)) {
162            $errors[] = self::getError('invalidServiceCount');
163        }
164
165        return ['errors' => $errors];
166    }
167
168    public static function validatePostAppointmentReserve(?int $officeId, ?array $serviceIds, ?array $serviceCounts, ?int $timestamp): array
169    {
170        $errors = [];
171        if (!self::isValidOfficeId($officeId)) {
172            $errors[] = self::getError('invalidOfficeId');
173        }
174
175        if (!self::isValidServiceIds($serviceIds)) {
176            $errors[] = self::getError('invalidServiceId');
177        }
178
179        if (!self::isValidTimestamp($timestamp)) {
180            $errors[] = self::getError('invalidTimestamp');
181        }
182
183        if (!self::isValidServiceCounts($serviceCounts)) {
184            $errors[] = self::getError('invalidServiceCount');
185        }
186
187        return ['errors' => $errors];
188    }
189
190    public static function validateAppointmentUpdateFields(
191        ?string $familyName,
192        ?string $email,
193        ?string $telephone,
194        ?string $customTextfield,
195        ?ThinnedScope $scope
196    ): array {
197        $errors = [];
198
199        self::validateFamilyNameField($familyName, $errors);
200        self::validateEmailField($email, $scope, $errors);
201        self::validateTelephoneField($telephone, $scope, $errors);
202        self::validateCustomTextField($customTextfield, $scope, $errors);
203
204        return ['errors' => $errors];
205    }
206
207    private static function validateFamilyNameField(?string $familyName, array &$errors): void
208    {
209        if (!self::isValidFamilyName($familyName)) {
210            $errors[] = self::getError('invalidFamilyName');
211        }
212    }
213
214    private static function validateEmailField(?string $email, ?ThinnedScope $scope, array &$errors): void
215    {
216        if ($scope && $scope->emailRequired && !self::isValidEmail($email)) {
217            $errors[] = self::getError('invalidEmail');
218        }
219    }
220
221    private static function validateTelephoneField(?string $telephone, ?ThinnedScope $scope, array &$errors): void
222    {
223        if (!$scope || !$scope->telephoneActivated) {
224            return;
225        }
226
227        if (
228            ($scope->telephoneRequired && !self::isValidTelephone($telephone)) ||
229            ($telephone !== null && !self::isValidTelephone($telephone))
230        ) {
231            $errors[] = self::getError('invalidTelephone');
232        }
233    }
234
235    private static function validateCustomTextField(?string $customTextfield, ?ThinnedScope $scope, array &$errors): void
236    {
237        if (!$scope || !$scope->customTextfieldActivated) {
238            return;
239        }
240
241        if (
242            ($scope->customTextfieldRequired && !self::isValidCustomTextfield($customTextfield)) ||
243            ($customTextfield !== null && !self::isValidCustomTextfield($customTextfield))
244        ) {
245            $errors[] = self::getError('invalidCustomTextfield');
246        }
247    }
248
249    public static function validateGetScopeById(?int $scopeId): array
250    {
251        return !self::isValidScopeId($scopeId)
252            ? ['errors' => [self::getError('invalidScopeId')]]
253            : [];
254    }
255
256    public static function validateGetServicesByOfficeId(?int $officeId): array
257    {
258        return !self::isValidOfficeId($officeId)
259            ? ['errors' => [self::getError('invalidOfficeId')]]
260            : [];
261    }
262
263    public static function validateGetOfficeListByServiceId(?int $serviceId): array
264    {
265        return !self::isValidServiceId($serviceId)
266            ? ['errors' => [self::getError('invalidServiceId')]]
267            : [];
268    }
269
270    public static function validateGetProcessFreeSlots(?ProcessList $freeSlots): array
271    {
272        return empty($freeSlots) || !is_iterable($freeSlots)
273            ? ['errors' => [self::getError('appointmentNotAvailable')]]
274            : [];
275    }
276
277    public static function validateGetProcessByIdTimestamps(?array $appointmentTimestamps): array
278    {
279        return empty($appointmentTimestamps)
280            ? ['errors' => [self::getError('appointmentNotAvailable')]]
281            : [];
282    }
283
284    public static function validateGetProcessNotFound(?Process $process): array
285    {
286        return !$process
287            ? ['errors' => [self::getError('appointmentNotAvailable')]]
288            : [];
289    }
290
291    public static function validateScopesNotFound(?ScopeList $scopes): array
292    {
293        return empty($scopes) || $scopes === null || $scopes->count() === 0
294            ? ['errors' => [self::getError('scopesNotFound')]]
295            : [];
296    }
297
298    public static function validateServicesNotFound(?array $services): array
299    {
300        return empty($services)
301            ? ['errors' => [self::getError('requestNotFound')]]
302            : [];
303    }
304
305    public static function validateOfficesNotFound(?array $offices): array
306    {
307        return empty($offices)
308            ? ['errors' => [self::getError('providerNotFound')]]
309            : [];
310    }
311
312    public static function validateAppointmentDaysNotFound(?array $formattedDays): array
313    {
314        return empty($formattedDays)
315            ? ['errors' => [self::getError('noAppointmentForThisDay')]]
316            : [];
317    }
318
319    public static function validatenoAppointmentForThisScope(): array
320    {
321        return ['errors' => [self::getError('noAppointmentForThisScope')]];
322    }
323
324    public static function validateServiceArrays(array $serviceIds, array $serviceCounts): array
325    {
326        $errors = [];
327        if (empty($serviceIds) || empty($serviceCounts)) {
328            $errors[] = self::getError('emptyServiceArrays');
329        }
330
331        if (count($serviceIds) !== count($serviceCounts)) {
332            $errors[] = self::getError('mismatchedArrays');
333        }
334
335        foreach ($serviceIds as $id) {
336            if (!is_numeric($id)) {
337                $errors[] = self::getError('invalidServiceId');
338                break;
339            }
340        }
341
342        foreach ($serviceCounts as $count) {
343            if (!is_numeric($count) || $count < 0) {
344                $errors[] = self::getError('invalidServiceCount');
345                break;
346            }
347        }
348
349        return $errors;
350    }
351
352    /*  Helper methods for validation */
353    private static function isValidDate(string $date): bool
354    {
355        $dateTime = DateTime::createFromFormat(self::DATE_FORMAT, $date);
356        return $dateTime && $dateTime->format(self::DATE_FORMAT) === $date;
357    }
358
359    private static function isDateRangeValid(string $startDate, string $endDate): bool
360    {
361        $start = new DateTime($startDate);
362        $end = new DateTime($endDate);
363        $diff = $start->diff($end);
364        return $diff->days <= self::MAX_FUTURE_DAYS;
365    }
366
367    private static function isValidNumericArray(array $array): bool
368    {
369        return !empty($array) && array_filter($array, 'is_numeric') === $array;
370    }
371
372    private static function isValidOfficeIds(?array $officeIds): bool
373    {
374        return !empty($officeIds) && self::isValidNumericArray($officeIds);
375    }
376
377    private static function isValidScopeId(?int $scopeId): bool
378    {
379        return !empty($scopeId) && $scopeId > 0;
380    }
381
382    private static function isValidProcessId(?int $processId): bool
383    {
384        return !empty($processId) && $processId >= self::MIN_PROCESS_ID;
385    }
386
387    private static function isValidAuthKey(?string $authKey): bool
388    {
389        return !empty($authKey) && is_string($authKey) && strlen(trim($authKey)) > 0;
390    }
391
392    private static function isValidServiceIds(?array $serviceIds): bool
393    {
394        return !empty($serviceIds) && self::isValidNumericArray($serviceIds);
395    }
396
397    private static function isValidServiceCounts(?array $serviceCounts): bool
398    {
399        if (empty($serviceCounts) || !is_array($serviceCounts)) {
400            return false;
401        }
402
403        foreach ($serviceCounts as $count) {
404            if (!is_numeric($count) || $count < 0 || !preg_match(self::SERVICE_COUNT_PATTERN, (string) $count)) {
405                return false;
406            }
407        }
408
409        return true;
410    }
411
412    private static function isValidTimestamp(?int $timestamp): bool
413    {
414        return !empty($timestamp) && is_numeric($timestamp) && $timestamp > time();
415    }
416
417    private static function isValidEmail(?string $email): bool
418    {
419        return !empty($email) && filter_var($email, FILTER_VALIDATE_EMAIL) !== false;
420    }
421
422    private static function isValidTelephone(?string $telephone): bool
423    {
424        return $telephone === null || preg_match(self::PHONE_PATTERN, $telephone);
425    }
426
427    private static function isValidFamilyName(?string $familyName): bool
428    {
429        return !empty($familyName) && is_string($familyName) && strlen(trim($familyName)) > 0;
430    }
431
432    private static function isValidCustomTextfield(?string $customTextfield): bool
433    {
434        return $customTextfield === null || (is_string($customTextfield) && strlen(trim($customTextfield)) > 0);
435    }
436
437    private static function isValidOfficeId(?int $officeId): bool
438    {
439        return !empty($officeId) && $officeId > 0;
440    }
441
442    private static function isValidServiceId(?int $serviceId): bool
443    {
444        return !empty($serviceId) && $serviceId > 0;
445    }
446}