Code Coverage |
||||||||||
Lines |
Functions and Methods |
Classes and Traits |
||||||||
| Total | |
95.69% |
111 / 116 |
|
50.00% |
4 / 8 |
CRAP | |
0.00% |
0 / 1 |
| GroupOlxCompatibilitySuite | |
95.69% |
111 / 116 |
|
50.00% |
4 / 8 |
28 | |
0.00% |
0 / 1 |
| __construct | |
100.00% |
1 / 1 |
|
100.00% |
1 / 1 |
1 | |||
| run | |
100.00% |
46 / 46 |
|
100.00% |
1 / 1 |
6 | |||
| toReport | |
100.00% |
11 / 11 |
|
100.00% |
1 / 1 |
2 | |||
| toMarkdown | |
97.06% |
33 / 34 |
|
0.00% |
0 / 1 |
6 | |||
| check | |
100.00% |
1 / 1 |
|
100.00% |
1 / 1 |
1 | |||
| hasOfficialNodes | |
75.00% |
3 / 4 |
|
0.00% |
0 / 1 |
3.14 | |||
| structuresAlign | |
75.00% |
6 / 8 |
|
0.00% |
0 / 1 |
5.39 | |||
| essentialTags | |
90.91% |
10 / 11 |
|
0.00% |
0 / 1 |
4.01 | |||
| 1 | <?php |
| 2 | |
| 3 | declare(strict_types=1); |
| 4 | |
| 5 | namespace ImovelZapAi\Publishing\Connectors\GroupOlx\Application\Services; |
| 6 | |
| 7 | use DOMDocument; |
| 8 | use DOMXPath; |
| 9 | use ImovelZapAi\Publishing\Connectors\GroupOlx\Application\Data\GroupOlxCompatibilityReportData; |
| 10 | use ImovelZapAi\Publishing\Connectors\GroupOlx\Application\Ports\GroupOlxFeedValidatorInterface; |
| 11 | use ImovelZapAi\Publishing\Connectors\GroupOlx\Domain\GroupOlxConnector; |
| 12 | |
| 13 | /** |
| 14 | * Suíte de compatibilidade VRSync — compara XML gerado com exemplos oficiais. |
| 15 | * |
| 16 | * O conector é um adaptador de formato: produz feed VRSync válido, não "publica na OLX". |
| 17 | */ |
| 18 | final class GroupOlxCompatibilitySuite |
| 19 | { |
| 20 | public function __construct( |
| 21 | private readonly GroupOlxFeedValidatorInterface $validator, |
| 22 | ) {} |
| 23 | |
| 24 | /** |
| 25 | * @param array<string, string> $officialSamples nome => xml |
| 26 | * @return list<array{name: string, passed: bool, detail: string}> |
| 27 | */ |
| 28 | public function run(string $generatedXml, array $officialSamples = []): array |
| 29 | { |
| 30 | $checks = []; |
| 31 | |
| 32 | $checks[] = $this->check( |
| 33 | 'UTF-8', |
| 34 | str_contains($generatedXml, 'encoding="UTF-8"') && mb_check_encoding($generatedXml, 'UTF-8'), |
| 35 | 'Declaração e conteúdo UTF-8', |
| 36 | ); |
| 37 | |
| 38 | $validation = $this->validator->validateXml($generatedXml); |
| 39 | $checks[] = $this->check( |
| 40 | 'Campos obrigatórios', |
| 41 | $validation->isValid(), |
| 42 | $validation->isValid() ? 'Validator sem erros' : implode(' | ', array_slice($validation->errors, 0, 5)), |
| 43 | ); |
| 44 | |
| 45 | $checks[] = $this->check( |
| 46 | 'Namespace VRSync', |
| 47 | str_contains($generatedXml, GroupOlxConnector::FEED_NAMESPACE), |
| 48 | GroupOlxConnector::FEED_NAMESPACE, |
| 49 | ); |
| 50 | |
| 51 | $listingCount = preg_match_all('/<Listing(?:\s|>)/', $generatedXml) ?: 0; |
| 52 | $checks[] = $this->check( |
| 53 | 'Múltiplos imóveis', |
| 54 | $listingCount >= 1, |
| 55 | $listingCount.' listing(s) no XML gerado', |
| 56 | ); |
| 57 | |
| 58 | $imageCount = preg_match_all('/<Item[^>]*medium="image"/', $generatedXml) ?: 0; |
| 59 | $checks[] = $this->check( |
| 60 | 'Imagens', |
| 61 | $imageCount >= 1, |
| 62 | $imageCount.' imagem(ns) Media/Item', |
| 63 | ); |
| 64 | |
| 65 | $checks[] = $this->check( |
| 66 | 'Caracteres especiais', |
| 67 | ! preg_match('/[\x00-\x08\x0B\x0C\x0E-\x1F]/', $generatedXml), |
| 68 | 'Sem caracteres de controle inválidos', |
| 69 | ); |
| 70 | |
| 71 | $checks[] = $this->check( |
| 72 | 'Nós oficiais', |
| 73 | $this->hasOfficialNodes($generatedXml), |
| 74 | 'ListingID, Title, TransactionType, Details, Location, Media, DetailViewUrl', |
| 75 | ); |
| 76 | |
| 77 | foreach ($officialSamples as $name => $officialXml) { |
| 78 | $checks[] = $this->check( |
| 79 | 'Comparação estrutural: '.$name, |
| 80 | $this->structuresAlign($generatedXml, $officialXml), |
| 81 | 'Mesmos nós raiz/Header/Listings/Listing essenciais do exemplo oficial', |
| 82 | ); |
| 83 | } |
| 84 | |
| 85 | return $checks; |
| 86 | } |
| 87 | |
| 88 | /** |
| 89 | * @param list<array{name: string, passed: bool, detail: string}> $checks |
| 90 | */ |
| 91 | public function toReport(array $checks, string $generatedXml): GroupOlxCompatibilityReportData |
| 92 | { |
| 93 | $passed = count(array_filter($checks, static fn (array $c): bool => $c['passed'])); |
| 94 | $total = count($checks); |
| 95 | |
| 96 | return new GroupOlxCompatibilityReportData( |
| 97 | generatedAt: now(), |
| 98 | checks: $checks, |
| 99 | passedCount: $passed, |
| 100 | totalCount: $total, |
| 101 | listingCount: preg_match_all('/<Listing(?:\s|>)/', $generatedXml) ?: 0, |
| 102 | xmlBytes: strlen($generatedXml), |
| 103 | utf8: mb_check_encoding($generatedXml, 'UTF-8'), |
| 104 | ); |
| 105 | } |
| 106 | |
| 107 | public function toMarkdown(GroupOlxCompatibilityReportData $report): string |
| 108 | { |
| 109 | $lines = [ |
| 110 | '# Relatório de Compatibilidade — Grupo OLX (VRSync)', |
| 111 | '', |
| 112 | '**Gerado em:** '.$report->generatedAt->timezone(config('app.timezone'))->format('d/m/Y H:i:s'), |
| 113 | '', |
| 114 | '## Princípio', |
| 115 | '', |
| 116 | 'O conector GroupOlx é um **adaptador de formato**: produz um feed VRSync válido e o disponibiliza publicamente.', |
| 117 | 'A distribuição para OLX / ZAP / Viva Real segue a plataforma e a conta da imobiliária — não é “publicar na OLX” via API.', |
| 118 | '', |
| 119 | '## Resumo', |
| 120 | '', |
| 121 | sprintf('- **Resultado:** %s (%d/%d)', $report->isFullyCompatible() ? 'COMPATÍVEL' : 'FALHAS', $report->passedCount, $report->totalCount), |
| 122 | sprintf('- **Listings:** %d', $report->listingCount), |
| 123 | sprintf('- **Tamanho XML:** %d bytes', $report->xmlBytes), |
| 124 | sprintf('- **UTF-8:** %s', $report->utf8 ? 'sim' : 'não'), |
| 125 | '', |
| 126 | '## Checks', |
| 127 | '', |
| 128 | '| Check | Status | Detalhe |', |
| 129 | '|-------|--------|---------|', |
| 130 | ]; |
| 131 | |
| 132 | foreach ($report->checks as $check) { |
| 133 | $status = $check['passed'] ? 'PASS' : 'FAIL'; |
| 134 | $detail = str_replace('|', '\\|', $check['detail']); |
| 135 | $lines[] = sprintf('| %s | %s | %s |', $check['name'], $status, $detail); |
| 136 | } |
| 137 | |
| 138 | $lines[] = ''; |
| 139 | $lines[] = '## Conclusão'; |
| 140 | $lines[] = ''; |
| 141 | $lines[] = $report->isFullyCompatible() |
| 142 | ? 'Feed gerado alinhado aos exemplos oficiais VRSync e às regras do validator.' |
| 143 | : 'Corrigir os checks FAIL antes de disponibilizar o feed para importação.'; |
| 144 | $lines[] = ''; |
| 145 | |
| 146 | return implode("\n", $lines); |
| 147 | } |
| 148 | |
| 149 | /** |
| 150 | * @return array{name: string, passed: bool, detail: string} |
| 151 | */ |
| 152 | private function check(string $name, bool $passed, string $detail): array |
| 153 | { |
| 154 | return compact('name', 'passed', 'detail'); |
| 155 | } |
| 156 | |
| 157 | private function hasOfficialNodes(string $xml): bool |
| 158 | { |
| 159 | foreach (['ListingID', 'Title', 'TransactionType', 'Details', 'Location', 'Media', 'DetailViewUrl'] as $node) { |
| 160 | if (! str_contains($xml, '<'.$node)) { |
| 161 | return false; |
| 162 | } |
| 163 | } |
| 164 | |
| 165 | return true; |
| 166 | } |
| 167 | |
| 168 | private function structuresAlign(string $generated, string $official): bool |
| 169 | { |
| 170 | $gen = $this->essentialTags($generated); |
| 171 | $off = $this->essentialTags($official); |
| 172 | |
| 173 | if ($gen === [] || $off === []) { |
| 174 | return false; |
| 175 | } |
| 176 | |
| 177 | foreach ($off as $tag) { |
| 178 | if (! in_array($tag, $gen, true)) { |
| 179 | return false; |
| 180 | } |
| 181 | } |
| 182 | |
| 183 | return true; |
| 184 | } |
| 185 | |
| 186 | /** @return list<string> */ |
| 187 | private function essentialTags(string $xml): array |
| 188 | { |
| 189 | $document = new DOMDocument(); |
| 190 | if (@$document->loadXML($xml) === false) { |
| 191 | return []; |
| 192 | } |
| 193 | |
| 194 | $xpath = new DOMXPath($document); |
| 195 | $xpath->registerNamespace('vr', GroupOlxConnector::FEED_NAMESPACE); |
| 196 | |
| 197 | $tags = []; |
| 198 | foreach (['ListingDataFeed', 'Header', 'Provider', 'Email', 'Listings', 'Listing', 'ListingID', 'Title', 'TransactionType', 'Details', 'Location', 'Media'] as $tag) { |
| 199 | $query = '//*[local-name()="'.$tag.'"]'; |
| 200 | if ($xpath->query($query)->length > 0) { |
| 201 | $tags[] = $tag; |
| 202 | } |
| 203 | } |
| 204 | |
| 205 | return $tags; |
| 206 | } |
| 207 | } |