Code Coverage
 
Lines
Functions and Methods
Classes and Traits
Total
96.12% covered (success)
96.12%
124 / 129
78.57% covered (warning)
78.57%
11 / 14
CRAP
0.00% covered (danger)
0.00%
0 / 1
FeedStorage
96.12% covered (success)
96.12%
124 / 129
78.57% covered (warning)
78.57%
11 / 14
37
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
 fromConfig
93.33% covered (success)
93.33%
14 / 15
0.00% covered (danger)
0.00%
0 / 1
3.00
 store
100.00% covered (success)
100.00%
30 / 30
100.00% covered (success)
100.00%
1 / 1
1
 latest
88.46% covered (warning)
88.46%
23 / 26
0.00% covered (danger)
0.00%
0 / 1
9.12
 get
100.00% covered (success)
100.00%
1 / 1
100.00% covered (success)
100.00%
1 / 1
1
 exists
100.00% covered (success)
100.00%
4 / 4
100.00% covered (success)
100.00%
1 / 1
2
 delete
100.00% covered (success)
100.00%
7 / 7
100.00% covered (success)
100.00%
1 / 1
4
 lastModified
100.00% covered (success)
100.00%
6 / 6
100.00% covered (success)
100.00%
1 / 1
3
 driver
100.00% covered (success)
100.00%
1 / 1
100.00% covered (success)
100.00%
1 / 1
1
 filesystem
100.00% covered (success)
100.00%
1 / 1
100.00% covered (success)
100.00%
1 / 1
1
 basePath
100.00% covered (success)
100.00%
1 / 1
100.00% covered (success)
100.00%
1 / 1
1
 legacyPath
100.00% covered (success)
100.00%
5 / 5
100.00% covered (success)
100.00%
1 / 1
1
 latestFromLegacy
94.74% covered (success)
94.74%
18 / 19
0.00% covered (danger)
0.00%
0 / 1
6.01
 pruneOldVersions
100.00% covered (success)
100.00%
12 / 12
100.00% covered (success)
100.00%
1 / 1
3
1<?php
2
3declare(strict_types=1);
4
5namespace ImovelZapAi\Publishing\Connectors\GroupOlx\Infrastructure\Storage;
6
7use DateTimeImmutable;
8use DateTimeZone;
9use Illuminate\Contracts\Filesystem\Filesystem;
10use Illuminate\Support\Facades\Storage;
11use ImovelZapAi\Publishing\Connectors\GroupOlx\Application\Data\FeedStorageVersionData;
12use ImovelZapAi\Publishing\Connectors\GroupOlx\Application\Ports\FeedStorageInterface;
13use ImovelZapAi\Publishing\Connectors\GroupOlx\Domain\Enums\FeedStorageDriver;
14
15/**
16 * FeedStorage baseado em disco Laravel (Local ou Cloudflare R2 via S3).
17 *
18 * Estrutura:
19 *   {base}/current.xml
20 *   {base}/meta.json
21 *   {base}/versions/{version}_{hash8}.xml
22 */
23final class FeedStorage implements FeedStorageInterface
24{
25    public function __construct(
26        private readonly FeedStorageDriver $driver,
27        private readonly string $disk,
28        private readonly string $pathPrefixTemplate,
29        private readonly int $keepVersions = 10,
30    ) {}
31
32    public static function fromConfig(): self
33    {
34        $driver = FeedStorageDriver::tryFrom((string) config('publishing.group_olx.storage.driver', 'local'))
35            ?? FeedStorageDriver::Local;
36
37        $disk = match ($driver) {
38            FeedStorageDriver::Local => (string) config(
39                'publishing.group_olx.storage.local_disk',
40                config('publishing.group_olx.feed_cache.disk', 'local'),
41            ),
42            FeedStorageDriver::R2 => (string) config('publishing.group_olx.storage.r2_disk', 'r2'),
43        };
44
45        $prefix = (string) config(
46            'publishing.group_olx.storage.path_prefix',
47            'publishing/{tenant_id}/group-olx',
48        );
49
50        $keep = (int) config('publishing.group_olx.storage.keep_versions', 10);
51
52        return new self($driver, $disk, $prefix, max(1, $keep));
53    }
54
55    public function store(string $tenantId, string $xml): FeedStorageVersionData
56    {
57        $hash = hash('sha256', $xml);
58        $storedAt = new DateTimeImmutable('now', new DateTimeZone('UTC'));
59        $version = $storedAt->format('Ymd\THis\Z');
60        $base = $this->basePath($tenantId);
61        $versionPath = $base.'/versions/'.$version.'_'.substr($hash, 0, 8).'.xml';
62        $currentPath = $base.'/current.xml';
63        $metaPath = $base.'/meta.json';
64
65        $filesystem = $this->filesystem();
66        $filesystem->put($versionPath, $xml);
67        $filesystem->put($currentPath, $xml);
68        $filesystem->put($metaPath, json_encode([
69            'tenant_id' => $tenantId,
70            'version' => $version,
71            'hash' => $hash,
72            'stored_at' => $storedAt->format(DATE_ATOM),
73            'path' => $versionPath,
74            'current_path' => $currentPath,
75            'driver' => $this->driver->value,
76        ], JSON_UNESCAPED_SLASHES | JSON_THROW_ON_ERROR));
77
78        $this->pruneOldVersions($filesystem, $base, $versionPath);
79
80        return new FeedStorageVersionData(
81            tenantId: $tenantId,
82            version: $version,
83            hash: $hash,
84            storedAt: $storedAt,
85            path: $versionPath,
86            currentPath: $currentPath,
87            driver: $this->driver,
88            xml: $xml,
89        );
90    }
91
92    public function latest(string $tenantId, bool $withXml = true): ?FeedStorageVersionData
93    {
94        $metaPath = $this->basePath($tenantId).'/meta.json';
95        $filesystem = $this->filesystem();
96
97        if (! $filesystem->exists($metaPath)) {
98            // Compatibilidade com path legado (Etapa 5).
99            return $this->latestFromLegacy($tenantId, $withXml);
100        }
101
102        $raw = $filesystem->get($metaPath);
103
104        if (! is_string($raw) || $raw === '') {
105            return null;
106        }
107
108        /** @var array{tenant_id?: string, version?: string, hash?: string, stored_at?: string, path?: string, current_path?: string, driver?: string} $meta */
109        $meta = json_decode($raw, true, 512, JSON_THROW_ON_ERROR);
110
111        if (! isset($meta['version'], $meta['hash'], $meta['stored_at'], $meta['path'], $meta['current_path'])) {
112            return null;
113        }
114
115        $xml = null;
116        if ($withXml) {
117            $contents = $filesystem->get($meta['current_path']);
118            $xml = is_string($contents) && $contents !== '' ? $contents : null;
119
120            if ($xml === null) {
121                return null;
122            }
123        }
124
125        return new FeedStorageVersionData(
126            tenantId: $tenantId,
127            version: $meta['version'],
128            hash: $meta['hash'],
129            storedAt: new DateTimeImmutable($meta['stored_at']),
130            path: $meta['path'],
131            currentPath: $meta['current_path'],
132            driver: FeedStorageDriver::tryFrom((string) ($meta['driver'] ?? $this->driver->value)) ?? $this->driver,
133            xml: $xml,
134        );
135    }
136
137    public function get(string $tenantId): ?string
138    {
139        return $this->latest($tenantId, withXml: true)?->xml;
140    }
141
142    public function exists(string $tenantId): bool
143    {
144        $filesystem = $this->filesystem();
145        $base = $this->basePath($tenantId);
146
147        return $filesystem->exists($base.'/current.xml')
148            || $filesystem->exists($this->legacyPath($tenantId));
149    }
150
151    public function delete(string $tenantId): void
152    {
153        $filesystem = $this->filesystem();
154        $base = $this->basePath($tenantId);
155
156        if ($filesystem->exists($base.'/meta.json') || $filesystem->exists($base.'/current.xml')) {
157            $filesystem->deleteDirectory($base);
158        }
159
160        $legacy = $this->legacyPath($tenantId);
161        if ($filesystem->exists($legacy)) {
162            $filesystem->delete($legacy);
163        }
164    }
165
166    public function lastModified(string $tenantId): ?int
167    {
168        $filesystem = $this->filesystem();
169        $current = $this->basePath($tenantId).'/current.xml';
170
171        if ($filesystem->exists($current)) {
172            return $filesystem->lastModified($current);
173        }
174
175        $legacy = $this->legacyPath($tenantId);
176
177        return $filesystem->exists($legacy) ? $filesystem->lastModified($legacy) : null;
178    }
179
180    public function driver(): FeedStorageDriver
181    {
182        return $this->driver;
183    }
184
185    private function filesystem(): Filesystem
186    {
187        return Storage::disk($this->disk);
188    }
189
190    private function basePath(string $tenantId): string
191    {
192        return str_replace('{tenant_id}', $tenantId, $this->pathPrefixTemplate);
193    }
194
195    private function legacyPath(string $tenantId): string
196    {
197        $template = (string) config(
198            'publishing.group_olx.feed_cache.path_template',
199            'publishing/{tenant_id}/group-olx/vrsync.xml',
200        );
201
202        return str_replace('{tenant_id}', $tenantId, $template);
203    }
204
205    private function latestFromLegacy(string $tenantId, bool $withXml): ?FeedStorageVersionData
206    {
207        $path = $this->legacyPath($tenantId);
208        $filesystem = $this->filesystem();
209
210        if (! $filesystem->exists($path)) {
211            return null;
212        }
213
214        $xml = $filesystem->get($path);
215
216        if (! is_string($xml) || $xml === '') {
217            return null;
218        }
219
220        $mtime = $filesystem->lastModified($path) ?: time();
221        $storedAt = (new DateTimeImmutable('@'.$mtime))->setTimezone(new DateTimeZone('UTC'));
222
223        return new FeedStorageVersionData(
224            tenantId: $tenantId,
225            version: $storedAt->format('Ymd\THis\Z'),
226            hash: hash('sha256', $xml),
227            storedAt: $storedAt,
228            path: $path,
229            currentPath: $path,
230            driver: $this->driver,
231            xml: $withXml ? $xml : null,
232        );
233    }
234
235    private function pruneOldVersions(Filesystem $filesystem, string $base, string $keepPath): void
236    {
237        $versionsDir = $base.'/versions';
238        $others = collect($filesystem->files($versionsDir))
239            ->reject(static fn (string $file): bool => $file === $keepPath)
240            ->sort()
241            ->values()
242            ->all();
243
244        $allowedOthers = max(0, $this->keepVersions - 1);
245        $excess = count($others) - $allowedOthers;
246
247        if ($excess <= 0) {
248            return;
249        }
250
251        foreach (array_slice($others, 0, $excess) as $file) {
252            $filesystem->delete($file);
253        }
254    }
255}