Code Coverage
 
Lines
Functions and Methods
Classes and Traits
Total
96.08% covered (success)
96.08%
49 / 51
60.00% covered (warning)
60.00%
3 / 5
CRAP
0.00% covered (danger)
0.00%
0 / 1
FeedController
96.08% covered (success)
96.08%
49 / 51
60.00% covered (warning)
60.00%
3 / 5
22
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
 show
96.43% covered (success)
96.43%
27 / 28
0.00% covered (danger)
0.00%
0 / 1
9
 baseHeaders
100.00% covered (success)
100.00%
8 / 8
100.00% covered (success)
100.00%
1 / 1
2
 notModified
91.67% covered (success)
91.67%
11 / 12
0.00% covered (danger)
0.00%
0 / 1
9.05
 wantsGzip
100.00% covered (success)
100.00%
2 / 2
100.00% covered (success)
100.00%
1 / 1
1
1<?php
2
3declare(strict_types=1);
4
5namespace ImovelZapAi\Publishing\Connectors\GroupOlx\Presentation\Http\Controllers;
6
7use Illuminate\Http\Request;
8use Illuminate\Http\Response;
9use ImovelZapAi\Publishing\Application\Services\TenantPublishingConfigResolver;
10use ImovelZapAi\Publishing\Connectors\GroupOlx\Application\Ports\FeedStorageInterface;
11use ImovelZapAi\Publishing\Domain\Enums\TenantPublishingStatus;
12use ImovelZapAi\Tenancy\Domain\Enums\TenantStatus;
13use ImovelZapAi\Tenancy\Infrastructure\Persistence\Eloquent\TenantModel;
14
15/**
16 * Serve o feed VRSync oficial do Grupo OLX.
17 *
18 * Nunca gera XML na requisição — lê apenas o arquivo pré-gerado em cache.
19 */
20final class FeedController
21{
22    public function __construct(
23        private readonly FeedStorageInterface $storage,
24        private readonly TenantPublishingConfigResolver $publishingConfig,
25    ) {}
26
27    public function show(Request $request, TenantModel $tenant): Response
28    {
29        if ($tenant->status !== TenantStatus::Active) {
30            abort(404);
31        }
32
33        $config = $this->publishingConfig->forTenant((string) $tenant->id);
34
35        if ($config->publishingStatus === TenantPublishingStatus::Paused) {
36            abort(404, 'Feed XML pausado pela imobiliária.');
37        }
38
39        $xml = $this->storage->get((string) $tenant->id);
40
41        if ($xml === null) {
42            abort(404, 'Feed VRSync ainda não disponível.');
43        }
44
45        $etag = '"'.hash('sha256', $xml).'"';
46        $latest = $this->storage->latest((string) $tenant->id, withXml: false);
47        if ($latest !== null) {
48            $etag = $latest->etag();
49        }
50        $lastModified = $this->storage->lastModified((string) $tenant->id);
51        $lastModifiedHttp = $lastModified !== null
52            ? gmdate('D, d M Y H:i:s', $lastModified).' GMT'
53            : null;
54
55        if ($this->notModified($request, $etag, $lastModified)) {
56            return response('', 304, $this->baseHeaders($etag, $lastModifiedHttp));
57        }
58
59        $headers = $this->baseHeaders($etag, $lastModifiedHttp);
60        $body = $xml;
61
62        if ($this->wantsGzip($request)) {
63            $compressed = gzencode($xml, 9);
64
65            if ($compressed === false) {
66                // @codeCoverageIgnoreStart
67                // zlib indisponível — responde XML sem compressão.
68                // @codeCoverageIgnoreEnd
69            } else {
70                $body = $compressed;
71                $headers['Content-Encoding'] = 'gzip';
72                $headers['Vary'] = 'Accept-Encoding';
73                $headers['Content-Length'] = (string) strlen($compressed);
74            }
75        }
76
77        return response($body, 200, $headers);
78    }
79
80    /**
81     * @return array<string, string>
82     */
83    private function baseHeaders(string $etag, ?string $lastModifiedHttp): array
84    {
85        $headers = [
86            'Content-Type' => 'application/xml; charset=UTF-8',
87            'Cache-Control' => 'public, max-age='.(int) config('publishing.group_olx.feed_cache.max_age', config('publishing.feed_cache.max_age', 300)),
88            'ETag' => $etag,
89        ];
90
91        if ($lastModifiedHttp !== null) {
92            $headers['Last-Modified'] = $lastModifiedHttp;
93        }
94
95        return $headers;
96    }
97
98    private function notModified(Request $request, string $etag, ?int $lastModified): bool
99    {
100        $ifNoneMatch = $request->header('If-None-Match');
101
102        if (is_string($ifNoneMatch) && trim($ifNoneMatch) !== '') {
103            $candidates = array_map('trim', explode(',', $ifNoneMatch));
104
105            if (in_array($etag, $candidates, true) || in_array('*', $candidates, true)) {
106                return true;
107            }
108        }
109
110        if ($lastModified === null) {
111            return false;
112        }
113
114        $ifModifiedSince = $request->header('If-Modified-Since');
115
116        if (! is_string($ifModifiedSince) || trim($ifModifiedSince) === '') {
117            return false;
118        }
119
120        $since = strtotime($ifModifiedSince);
121
122        return $since !== false && $lastModified <= $since;
123    }
124
125    private function wantsGzip(Request $request): bool
126    {
127        $accept = strtolower((string) $request->header('Accept-Encoding', ''));
128
129        return str_contains($accept, 'gzip');
130    }
131}