diff --git a/app/Services/Social/LinkCard/LinkCardFetcher.php b/app/Services/Social/LinkCard/LinkCardFetcher.php index b4985799..d987cf42 100644 --- a/app/Services/Social/LinkCard/LinkCardFetcher.php +++ b/app/Services/Social/LinkCard/LinkCardFetcher.php @@ -32,11 +32,16 @@ public function fetch(string $text): ?LinkCardMetadata return null; } - return Cache::remember( + // Cache a plain array, never the DTO: a rich object does not round-trip + // cleanly through every cache driver (it comes back as an incomplete + // class), whereas primitives always do. + $data = Cache::remember( 'link_card:'.sha1($url), now()->addMinutes(self::CACHE_MINUTES), - fn (): ?LinkCardMetadata => $this->build($url), + fn (): ?array => $this->build($url)?->toArray(), ); + + return $data === null ? null : LinkCardMetadata::fromArray($data); } private function build(string $url): ?LinkCardMetadata diff --git a/app/Services/Social/LinkCard/LinkCardMetadata.php b/app/Services/Social/LinkCard/LinkCardMetadata.php index 53760b73..95d50346 100644 --- a/app/Services/Social/LinkCard/LinkCardMetadata.php +++ b/app/Services/Social/LinkCard/LinkCardMetadata.php @@ -16,6 +16,22 @@ public function __construct( public ?string $imageUrl, ) {} + /** + * Rebuild the DTO from its cached array form (the inverse of toArray, minus + * the derived `domain`, which toArray recomputes). + * + * @param array{uri?: string, title?: string, description?: string, image?: ?string} $data + */ + public static function fromArray(array $data): self + { + return new self( + uri: (string) data_get($data, 'uri', ''), + title: (string) data_get($data, 'title', ''), + description: (string) data_get($data, 'description', ''), + imageUrl: data_get($data, 'image'), + ); + } + /** * @return array{uri: string, domain: string, title: string, description: string, image: ?string} */ diff --git a/tests/Feature/Services/Social/LinkCard/LinkCardFetcherTest.php b/tests/Feature/Services/Social/LinkCard/LinkCardFetcherTest.php index 95d14810..ca024fdf 100644 --- a/tests/Feature/Services/Social/LinkCard/LinkCardFetcherTest.php +++ b/tests/Feature/Services/Social/LinkCard/LinkCardFetcherTest.php @@ -3,6 +3,8 @@ declare(strict_types=1); use App\Services\Social\LinkCard\LinkCardFetcher; +use App\Services\Social\LinkCard\LinkCardMetadata; +use Illuminate\Support\Facades\Cache; use Illuminate\Support\Facades\Http; // example.com resolves to a public IP so the SafeHttpFetcher SSRF guard passes; @@ -50,8 +52,16 @@ ]); $fetcher = app(LinkCardFetcher::class); - $fetcher->fetch('https://example.com'); - $fetcher->fetch('https://example.com'); + $first = $fetcher->fetch('https://example.com'); + $second = $fetcher->fetch('https://example.com'); Http::assertSentCount(1); + + // The cache must hold a primitive array, not the DTO — a rich object does + // not round-trip through every cache driver (it comes back as an incomplete + // class). A cache hit still reconstructs a proper DTO. + expect(Cache::get('link_card:'.sha1('https://example.com')))->toBeArray(); + expect($second)->toBeInstanceOf(LinkCardMetadata::class) + ->and($second->uri)->toBe($first->uri) + ->and($second->title)->toBe('Cached'); });