Code Coverage
 
Lines
Functions and Methods
Classes and Traits
Total
95.69% covered (success)
95.69%
111 / 116
50.00% covered (danger)
50.00%
4 / 8
CRAP
0.00% covered (danger)
0.00%
0 / 1
GroupOlxCompatibilitySuite
95.69% covered (success)
95.69%
111 / 116
50.00% covered (danger)
50.00%
4 / 8
28
0.00% covered (danger)
0.00%
0 / 1
 __construct
100.00% covered (success)
100.00%
1 / 1
100.00% covered (success)
100.00%
1 / 1
1
 run
100.00% covered (success)
100.00%
46 / 46
100.00% covered (success)
100.00%
1 / 1
6
 toReport
100.00% covered (success)
100.00%
11 / 11
100.00% covered (success)
100.00%
1 / 1
2
 toMarkdown
97.06% covered (success)
97.06%
33 / 34
0.00% covered (danger)
0.00%
0 / 1
6
 check
100.00% covered (success)
100.00%
1 / 1
100.00% covered (success)
100.00%
1 / 1
1
 hasOfficialNodes
75.00% covered (warning)
75.00%
3 / 4
0.00% covered (danger)
0.00%
0 / 1
3.14
 structuresAlign
75.00% covered (warning)
75.00%
6 / 8
0.00% covered (danger)
0.00%
0 / 1
5.39
 essentialTags
90.91% covered (success)
90.91%
10 / 11
0.00% covered (danger)
0.00%
0 / 1
4.01
1<?php
2
3declare(strict_types=1);
4
5namespace ImovelZapAi\Publishing\Connectors\GroupOlx\Application\Services;
6
7use DOMDocument;
8use DOMXPath;
9use ImovelZapAi\Publishing\Connectors\GroupOlx\Application\Data\GroupOlxCompatibilityReportData;
10use ImovelZapAi\Publishing\Connectors\GroupOlx\Application\Ports\GroupOlxFeedValidatorInterface;
11use 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 */
18final 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}