Code Coverage
 
Lines
Functions and Methods
Classes and Traits
Total
89.43% covered (warning)
89.43%
203 / 227
44.44% covered (danger)
44.44%
8 / 18
CRAP
0.00% covered (danger)
0.00%
0 / 1
FeedValidator
89.43% covered (warning)
89.43%
203 / 227
44.44% covered (danger)
44.44%
8 / 18
148.67
0.00% covered (danger)
0.00%
0 / 1
 validateXml
94.44% covered (success)
94.44%
17 / 18
0.00% covered (danger)
0.00%
0 / 1
6.01
 validateFeed
83.33% covered (warning)
83.33%
5 / 6
0.00% covered (danger)
0.00%
0 / 1
3.04
 validateBuild
100.00% covered (success)
100.00%
3 / 3
100.00% covered (success)
100.00%
1 / 1
1
 validateMapped
100.00% covered (success)
100.00%
3 / 3
100.00% covered (success)
100.00%
1 / 1
1
 validateMappedFeed
83.33% covered (warning)
83.33%
5 / 6
0.00% covered (danger)
0.00%
0 / 1
3.04
 validateHeader
92.31% covered (success)
92.31%
12 / 13
0.00% covered (danger)
0.00%
0 / 1
9.04
 validateMappedListing
66.67% covered (warning)
66.67%
6 / 9
0.00% covered (danger)
0.00%
0 / 1
10.37
 validateListing
100.00% covered (success)
100.00%
43 / 43
100.00% covered (success)
100.00%
1 / 1
21
 validatePricesForTransaction
77.78% covered (warning)
77.78%
7 / 9
0.00% covered (danger)
0.00%
0 / 1
8.70
 validateMedia
77.78% covered (warning)
77.78%
7 / 9
0.00% covered (danger)
0.00%
0 / 1
5.27
 validateCoordinates
86.67% covered (warning)
86.67%
13 / 15
0.00% covered (danger)
0.00%
0 / 1
13.40
 validateDom
86.30% covered (warning)
86.30%
63 / 73
0.00% covered (danger)
0.00%
0 / 1
39.33
 invalidCharactersIn
80.00% covered (warning)
80.00%
4 / 5
0.00% covered (danger)
0.00%
0 / 1
3.07
 isValidDate
100.00% covered (success)
100.00%
4 / 4
100.00% covered (success)
100.00%
1 / 1
3
 isValidUrl
100.00% covered (success)
100.00%
2 / 2
100.00% covered (success)
100.00%
1 / 1
2
 firstLibXmlError
100.00% covered (success)
100.00%
3 / 3
100.00% covered (success)
100.00%
1 / 1
2
 nodeText
100.00% covered (success)
100.00%
4 / 4
100.00% covered (success)
100.00%
1 / 1
3
 optionalNodeText
100.00% covered (success)
100.00%
2 / 2
100.00% covered (success)
100.00%
1 / 1
2
1<?php
2
3declare(strict_types=1);
4
5namespace ImovelZapAi\Publishing\Connectors\GroupOlx\Application\Services;
6
7use DOMDocument;
8use DOMElement;
9use DOMNode;
10use DOMXPath;
11use ImovelZapAi\Publishing\Connectors\GroupOlx\Application\Data\GroupOlxFeedBuildResultData;
12use ImovelZapAi\Publishing\Connectors\GroupOlx\Application\Data\GroupOlxFeedValidationResult;
13use ImovelZapAi\Publishing\Connectors\GroupOlx\Application\Data\GroupOlxMappedListingData;
14use ImovelZapAi\Publishing\Connectors\GroupOlx\Application\Ports\GroupOlxFeedValidatorInterface;
15use ImovelZapAi\Publishing\Connectors\GroupOlx\Domain\Enums\GroupOlxPropertyType;
16use ImovelZapAi\Publishing\Connectors\GroupOlx\Domain\Enums\GroupOlxTransactionType;
17use ImovelZapAi\Publishing\Connectors\GroupOlx\Domain\GroupOlxConnector;
18use ImovelZapAi\Publishing\Domain\Feeds\VrSync\ListingDataFeed;
19use ImovelZapAi\Publishing\Domain\Feeds\VrSync\ListingDataFeedHeader;
20use ImovelZapAi\Publishing\Domain\Feeds\VrSync\ListingDataFeedListing;
21use ImovelZapAi\Publishing\Domain\Feeds\VrSync\ListingDataFeedMediaItem;
22use ImovelZapAi\Publishing\Domain\Feeds\VrSync\VrSyncEnumerations;
23
24/**
25 * Valida feed VRSync (Grupo OLX) e retorna lista completa de erros.
26 *
27 * Nunca lança Exception para erro de validação.
28 */
29final class FeedValidator implements GroupOlxFeedValidatorInterface
30{
31    private const DATE_PATTERN = '/^\d{4}-\d{2}-\d{2}T\d{2}:\d{2}:\d{2}$/';
32
33    public function validateXml(string $xml): GroupOlxFeedValidationResult
34    {
35        $errors = [];
36
37        if ($xml === '') {
38            return new GroupOlxFeedValidationResult(['XML vazio.']);
39        }
40
41        if (! str_contains($xml, 'encoding="UTF-8"') && ! str_contains($xml, "encoding='UTF-8'")) {
42            $errors[] = 'XML deve declarar encoding UTF-8.';
43        }
44
45        if (! mb_check_encoding($xml, 'UTF-8')) {
46            $errors[] = 'Conteúdo XML não está em UTF-8 válido.';
47        }
48
49        $errors = array_merge($errors, $this->invalidCharactersIn('XML', $xml));
50
51        $document = new DOMDocument();
52        $previous = libxml_use_internal_errors(true);
53
54        if (! $document->loadXML($xml, LIBXML_NONET)) {
55            $errors[] = 'XML malformado: '.($this->firstLibXmlError() ?? 'parse falhou.');
56            libxml_clear_errors();
57            libxml_use_internal_errors($previous);
58
59            return new GroupOlxFeedValidationResult($errors);
60        }
61
62        libxml_clear_errors();
63        libxml_use_internal_errors($previous);
64
65        return new GroupOlxFeedValidationResult(array_merge($errors, $this->validateDom($document)));
66    }
67
68    public function validateFeed(ListingDataFeed $feed): GroupOlxFeedValidationResult
69    {
70        $errors = $this->validateHeader($feed->header);
71
72        if ($feed->listings === []) {
73            $errors[] = 'Listings deve conter ao menos um imóvel.';
74        }
75
76        foreach ($feed->listings as $index => $listing) {
77            $errors = array_merge($errors, $this->validateListing($listing, $index));
78        }
79
80        return new GroupOlxFeedValidationResult($errors);
81    }
82
83    public function validateBuild(GroupOlxFeedBuildResultData $build): GroupOlxFeedValidationResult
84    {
85        $feedErrors = $this->validateFeed($build->feed)->errors;
86        $xmlErrors = $this->validateXml($build->xml)->errors;
87
88        return new GroupOlxFeedValidationResult(array_values(array_unique([...$feedErrors, ...$xmlErrors])));
89    }
90
91    public function validateMapped(GroupOlxMappedListingData $mapped): GroupOlxFeedValidationResult
92    {
93        return new GroupOlxFeedValidationResult(
94            $this->validateMappedListing($mapped, 0),
95        );
96    }
97
98    public function validateMappedFeed(ListingDataFeedHeader $header, array $listings): GroupOlxFeedValidationResult
99    {
100        $errors = $this->validateHeader($header);
101
102        if ($listings === []) {
103            $errors[] = 'Listings deve conter ao menos um imóvel.';
104        }
105
106        foreach ($listings as $index => $mapped) {
107            $errors = array_merge($errors, $this->validateMappedListing($mapped, $index));
108        }
109
110        return new GroupOlxFeedValidationResult($errors);
111    }
112
113    /** @return list<string> */
114    private function validateHeader(ListingDataFeedHeader $header): array
115    {
116        $errors = [];
117
118        if (trim($header->provider) === '') {
119            $errors[] = 'Header.Provider é obrigatório.';
120        } else {
121            $errors = array_merge($errors, $this->invalidCharactersIn('Header.Provider', $header->provider));
122        }
123
124        if (trim($header->email) === '' || ! filter_var($header->email, FILTER_VALIDATE_EMAIL)) {
125            $errors[] = 'Header.Email inválido ou ausente.';
126        }
127
128        if (trim($header->publishDate) === '') {
129            $errors[] = 'Header.PublishDate é obrigatório.';
130        } elseif (! $this->isValidDate($header->publishDate)) {
131            $errors[] = 'Header.PublishDate deve estar no formato YYYY-MM-DDTHH:MM:SS.';
132        }
133
134        if ($header->logo !== null && $header->logo !== '' && ! $this->isValidUrl($header->logo)) {
135            $errors[] = 'Header.Logo deve ser uma URL válida.';
136        }
137
138        return $errors;
139    }
140
141    /** @return list<string> */
142    private function validateMappedListing(GroupOlxMappedListingData $mapped, int $index): array
143    {
144        $prefix = 'Listing #'.($index + 1);
145        $errors = $this->validateListing($mapped->listing, $index, $mapped);
146
147        if ($mapped->transactionType->usesListPrice() && ($mapped->listPrice === null || (float) $mapped->listPrice <= 0)) {
148            $errors[] = "{$prefix}: ListPrice é obrigatório para {$mapped->transactionType->value}.";
149        }
150
151        if ($mapped->transactionType->usesRentalPrice() && ($mapped->rentalPrice === null || (float) $mapped->rentalPrice <= 0)) {
152            $errors[] = "{$prefix}: RentalPrice é obrigatório para {$mapped->transactionType->value}.";
153        }
154
155        if ($mapped->usageType === '') {
156            $errors[] = "{$prefix}: UsageType é obrigatório.";
157        }
158
159        return $errors;
160    }
161
162    /** @return list<string> */
163    private function validateListing(
164        ListingDataFeedListing $listing,
165        int $index,
166        ?GroupOlxMappedListingData $mapped = null,
167    ): array {
168        $errors = [];
169        $prefix = 'Listing #'.($index + 1);
170
171        if (trim($listing->listingId) === '') {
172            $errors[] = "{$prefix}: ListingID é obrigatório.";
173        } else {
174            $errors = array_merge($errors, $this->invalidCharactersIn("{$prefix}.ListingID", $listing->listingId));
175        }
176
177        if (trim($listing->title) === '') {
178            $errors[] = "{$prefix}: Title é obrigatório.";
179        } else {
180            $errors = array_merge($errors, $this->invalidCharactersIn("{$prefix}.Title", $listing->title));
181        }
182
183        $transactionType = GroupOlxTransactionType::tryFromPurpose($listing->transactionType)
184            ?? GroupOlxTransactionType::tryFrom($listing->transactionType);
185
186        if ($transactionType === null) {
187            $errors[] = "{$prefix}: Finalidade/TransactionType inválido.";
188        }
189
190        $propertyType = GroupOlxPropertyType::tryFromInternal($listing->details->propertyType)
191            ?? GroupOlxPropertyType::tryFrom($listing->details->propertyType);
192
193        if ($propertyType === null) {
194            $errors[] = "{$prefix}: Categoria/PropertyType inválida.";
195        }
196
197        if (trim($listing->details->description) === '') {
198            $errors[] = "{$prefix}: Description é obrigatória.";
199        } else {
200            $errors = array_merge($errors, $this->invalidCharactersIn("{$prefix}.Description", $listing->details->description));
201        }
202
203        if (! VrSyncEnumerations::isCurrency($listing->details->currency)) {
204            $errors[] = "{$prefix}: Currency inválida.";
205        }
206
207        if ($mapped === null) {
208            $errors = array_merge($errors, $this->validatePricesForTransaction($listing, $transactionType, $prefix));
209        }
210
211        if (trim($listing->location->city) === '') {
212            $errors[] = "{$prefix}: City é obrigatória.";
213        }
214
215        if (trim($listing->location->state) === '') {
216            $errors[] = "{$prefix}: State é obrigatório.";
217        }
218
219        if ($listing->detailUrl !== null && $listing->detailUrl !== '' && ! $this->isValidUrl($listing->detailUrl)) {
220            $errors[] = "{$prefix}: DetailViewUrl inválida.";
221        }
222
223        if ($listing->listDate !== null && $listing->listDate !== '' && ! $this->isValidDate($listing->listDate)) {
224            $errors[] = "{$prefix}: ListDate inválida.";
225        }
226
227        if ($listing->lastUpdateDate !== null && $listing->lastUpdateDate !== '' && ! $this->isValidDate($listing->lastUpdateDate)) {
228            $errors[] = "{$prefix}: LastUpdateDate inválida.";
229        }
230
231        $errors = array_merge($errors, $this->validateCoordinates(
232            $listing->location->latitude,
233            $listing->location->longitude,
234            $prefix,
235        ));
236
237        if ($listing->media === []) {
238            $errors[] = "{$prefix}: ao menos uma imagem é obrigatória.";
239        }
240
241        foreach ($listing->media as $mediaIndex => $media) {
242            $errors = array_merge($errors, $this->validateMedia($media, $prefix, $mediaIndex));
243        }
244
245        return $errors;
246    }
247
248    /** @return list<string> */
249    private function validatePricesForTransaction(
250        ListingDataFeedListing $listing,
251        ?GroupOlxTransactionType $transactionType,
252        string $prefix,
253    ): array {
254        if ($transactionType === null) {
255            return [];
256        }
257
258        $errors = [];
259        $listPrice = $listing->details->listPrice;
260
261        if ($transactionType->usesListPrice() && ($listPrice === null || (float) $listPrice <= 0)) {
262            $errors[] = "{$prefix}: ListPrice é obrigatório para {$transactionType->value}.";
263        }
264
265        if ($transactionType === GroupOlxTransactionType::ForRent && $listPrice !== null && (float) $listPrice > 0) {
266            // Aceita ListPrice como fallback se RentalPrice ainda não estiver no DTO compartilhado.
267            return $errors;
268        }
269
270        return $errors;
271    }
272
273    /** @return list<string> */
274    private function validateMedia(ListingDataFeedMediaItem $media, string $prefix, int $mediaIndex): array
275    {
276        $label = "{$prefix} Media #".($mediaIndex + 1);
277        $errors = [];
278
279        if (! $this->isValidUrl($media->url)) {
280            $errors[] = "{$label}: URL de imagem inválida.";
281        } elseif (! in_array(parse_url($media->url, PHP_URL_SCHEME), ['http', 'https'], true)) {
282            $errors[] = "{$label}: imagem deve usar HTTP ou HTTPS.";
283        }
284
285        if ($media->caption !== null && $media->caption !== '') {
286            $errors = array_merge($errors, $this->invalidCharactersIn("{$label}.caption", $media->caption));
287        }
288
289        return $errors;
290    }
291
292    /** @return list<string> */
293    private function validateCoordinates(?string $latitude, ?string $longitude, string $prefix): array
294    {
295        $latitude = $latitude === '' ? null : $latitude;
296        $longitude = $longitude === '' ? null : $longitude;
297
298        if ($latitude === null && $longitude === null) {
299            return [];
300        }
301
302        if ($latitude === null || $longitude === null) {
303            return ["{$prefix}: Latitude e Longitude devem ser informadas em par."];
304        }
305
306        if (! is_numeric($latitude) || ! is_numeric($longitude)) {
307            return ["{$prefix}: coordenadas devem ser numéricas."];
308        }
309
310        $lat = (float) $latitude;
311        $lng = (float) $longitude;
312
313        if ($lat < -90 || $lat > 90) {
314            return ["{$prefix}: Latitude fora do intervalo (-90 a 90)."];
315        }
316
317        if ($lng < -180 || $lng > 180) {
318            return ["{$prefix}: Longitude fora do intervalo (-180 a 180)."];
319        }
320
321        return [];
322    }
323
324    /** @return list<string> */
325    private function validateDom(DOMDocument $document): array
326    {
327        $errors = [];
328        $xpath = new DOMXPath($document);
329        $xpath->registerNamespace('vr', GroupOlxConnector::FEED_NAMESPACE);
330
331        if ($xpath->query('//vr:ListingDataFeed')->length === 0) {
332            $errors[] = 'Elemento ListingDataFeed ausente ou namespace inválido.';
333
334            return $errors;
335        }
336
337        if ($xpath->query('//vr:Header/vr:Provider')->length === 0) {
338            $errors[] = 'Header.Provider ausente no XML.';
339        }
340
341        $publishDate = $this->nodeText($xpath, '//vr:Header/vr:PublishDate');
342        if ($publishDate === '') {
343            $errors[] = 'Header.PublishDate ausente no XML.';
344        } elseif (! $this->isValidDate($publishDate)) {
345            $errors[] = 'Header.PublishDate inválida no XML.';
346        }
347
348        $email = $this->nodeText($xpath, '//vr:Header/vr:Email');
349        if ($email === '' || ! filter_var($email, FILTER_VALIDATE_EMAIL)) {
350            $errors[] = 'Header.Email inválido ou ausente no XML.';
351        }
352
353        $listingNodes = $xpath->query('//vr:Listing');
354        if ($listingNodes === false || $listingNodes->length === 0) {
355            $errors[] = 'Nenhum Listing encontrado no XML.';
356
357            return $errors;
358        }
359
360        foreach ($listingNodes as $index => $listingNode) {
361            if (! $listingNode instanceof DOMElement) {
362                continue;
363            }
364
365            $label = 'Listing XML #'.($index + 1);
366
367            if ($this->nodeText($xpath, 'vr:ListingID', $listingNode) === '') {
368                $errors[] = "{$label}: ListingID ausente.";
369            }
370
371            if ($this->nodeText($xpath, 'vr:Title', $listingNode) === '') {
372                $errors[] = "{$label}: Title ausente.";
373            }
374
375            $transactionType = $this->nodeText($xpath, 'vr:TransactionType', $listingNode);
376            $parsedTransaction = GroupOlxTransactionType::tryFrom($transactionType);
377
378            if ($parsedTransaction === null) {
379                $errors[] = "{$label}: Finalidade/TransactionType inválido.";
380            }
381
382            $propertyType = $this->nodeText($xpath, 'vr:Details/vr:PropertyType', $listingNode);
383            if (GroupOlxPropertyType::tryFrom($propertyType) === null) {
384                $errors[] = "{$label}: Categoria/PropertyType inválida.";
385            }
386
387            $description = $this->nodeText($xpath, 'vr:Details/vr:Description', $listingNode);
388            if ($description === '') {
389                $errors[] = "{$label}: Description ausente.";
390            } else {
391                $errors = array_merge($errors, $this->invalidCharactersIn("{$label}.Description", $description));
392            }
393
394            $listPrice = $this->nodeText($xpath, 'vr:Details/vr:ListPrice', $listingNode);
395            $rentalPrice = $this->nodeText($xpath, 'vr:Details/vr:RentalPrice', $listingNode);
396
397            if ($parsedTransaction?->usesListPrice() && ($listPrice === '' || (float) $listPrice <= 0)) {
398                $errors[] = "{$label}: ListPrice é obrigatório.";
399            }
400
401            if ($parsedTransaction?->usesRentalPrice() && ($rentalPrice === '' || (float) $rentalPrice <= 0)) {
402                $errors[] = "{$label}: RentalPrice é obrigatório.";
403            }
404
405            $detailUrl = $this->nodeText($xpath, 'vr:DetailViewUrl', $listingNode);
406            if ($detailUrl === '') {
407                $detailUrl = $this->nodeText($xpath, 'vr:DetailUrl', $listingNode);
408            }
409            if ($detailUrl !== '' && ! $this->isValidUrl($detailUrl)) {
410                $errors[] = "{$label}: DetailViewUrl inválida.";
411            }
412
413            $listDate = $this->nodeText($xpath, 'vr:ListDate', $listingNode);
414            if ($listDate !== '' && ! $this->isValidDate($listDate)) {
415                $errors[] = "{$label}: ListDate inválida.";
416            }
417
418            $lastUpdate = $this->nodeText($xpath, 'vr:LastUpdateDate', $listingNode);
419            if ($lastUpdate !== '' && ! $this->isValidDate($lastUpdate)) {
420                $errors[] = "{$label}: LastUpdateDate inválida.";
421            }
422
423            if ($this->nodeText($xpath, 'vr:Location/vr:City', $listingNode) === '') {
424                $errors[] = "{$label}: City ausente.";
425            }
426
427            if ($this->nodeText($xpath, 'vr:Location/vr:State', $listingNode) === '') {
428                $errors[] = "{$label}: State ausente.";
429            }
430
431            $errors = array_merge($errors, $this->validateCoordinates(
432                $this->optionalNodeText($xpath, 'vr:Location/vr:Latitude', $listingNode),
433                $this->optionalNodeText($xpath, 'vr:Location/vr:Longitude', $listingNode),
434                $label,
435            ));
436
437            $mediaItems = $xpath->query('vr:Media/vr:Item', $listingNode);
438            if ($mediaItems === false || $mediaItems->length === 0) {
439                $errors[] = "{$label}: ao menos uma imagem é obrigatória.";
440            } else {
441                foreach ($mediaItems as $mediaIndex => $mediaNode) {
442                    $url = trim((string) $mediaNode->textContent);
443                    if (! $this->isValidUrl($url) || ! in_array(parse_url($url, PHP_URL_SCHEME), ['http', 'https'], true)) {
444                        $errors[] = "{$label} Media #".($mediaIndex + 1).': URL de imagem inválida.';
445                    }
446                }
447            }
448        }
449
450        return $errors;
451    }
452
453    /** @return list<string> */
454    private function invalidCharactersIn(string $field, string $value): array
455    {
456        if (str_contains($value, "\0")) {
457            return ["{$field}: contém caractere nulo inválido."];
458        }
459
460        if (preg_match('/[\x00-\x08\x0B\x0C\x0E-\x1F]/', $value) === 1) {
461            return ["{$field}: contém caracteres de controle inválidos."];
462        }
463
464        return [];
465    }
466
467    private function isValidDate(string $value): bool
468    {
469        if (preg_match(self::DATE_PATTERN, $value) !== 1) {
470            return false;
471        }
472
473        $date = \DateTimeImmutable::createFromFormat('Y-m-d\TH:i:s', $value);
474
475        return $date !== false && $date->format('Y-m-d\TH:i:s') === $value;
476    }
477
478    private function isValidUrl(string $url): bool
479    {
480        return filter_var(trim($url), FILTER_VALIDATE_URL) !== false
481            && in_array(parse_url(trim($url), PHP_URL_SCHEME), ['http', 'https'], true);
482    }
483
484    private function firstLibXmlError(): ?string
485    {
486        $error = libxml_get_last_error();
487
488        if ($error === false) {
489            return null; // @codeCoverageIgnore
490        }
491
492        return trim($error->message);
493    }
494
495    private function nodeText(DOMXPath $xpath, string $query, ?DOMNode $context = null): string
496    {
497        $node = $context === null
498            ? $xpath->query($query)?->item(0)
499            : $xpath->query($query, $context)?->item(0);
500
501        return $node !== null ? trim((string) $node->textContent) : '';
502    }
503
504    private function optionalNodeText(DOMXPath $xpath, string $query, DOMNode $context): ?string
505    {
506        $value = $this->nodeText($xpath, $query, $context);
507
508        return $value === '' ? null : $value;
509    }
510}