diff --git a/app-modules/portal/src/Articles/Article.php b/app-modules/portal/src/Articles/Article.php index bb9de8bd..22407594 100644 --- a/app-modules/portal/src/Articles/Article.php +++ b/app-modules/portal/src/Articles/Article.php @@ -5,6 +5,7 @@ namespace He4rt\Portal\Articles; use Carbon\CarbonImmutable; +use Throwable; final readonly class Article { @@ -31,9 +32,14 @@ public function __construct( * O corpo devolvido pela API é `array` — nunca a forma que * esperamos —, então cada campo é lido com guarda e default próprios. * + * Devolve `null` quando o item não sustenta as duas invariantes de um artigo + * exibível: ter título e ter data confiável. Sem data não há lugar na ordenação + * nem na janela de 12 meses do destaque, e `Carbon::parse('')` devolveria *hoje* + * — o item furado subiria ao topo do feed em vez de sumir. + * * @param array $payload */ - public static function fromApi(array $payload): self + public static function fromApi(array $payload): ?self { /** @var array $user */ $user = is_array($payload['user'] ?? null) ? $payload['user'] : []; @@ -44,20 +50,27 @@ public static function fromApi(array $payload): self is_string(...), )); + $title = self::text($payload['title'] ?? null); + $publishedAt = self::parseDate($payload['published_at'] ?? null); + + if ($title === '' || !$publishedAt instanceof CarbonImmutable) { + return null; + } + return new self( - id: (int) ($payload['id'] ?? 0), - title: (string) ($payload['title'] ?? ''), - description: (string) ($payload['description'] ?? ''), + id: self::number($payload['id'] ?? null), + title: $title, + description: self::text($payload['description'] ?? null), url: self::safeUrl($payload['url'] ?? null), - publishedAt: CarbonImmutable::parse((string) ($payload['published_at'] ?? 'now')), - reactions: (int) ($payload['positive_reactions_count'] ?? 0), - comments: (int) ($payload['comments_count'] ?? 0), - readingMinutes: (int) ($payload['reading_time_minutes'] ?? 0), + publishedAt: $publishedAt, + reactions: self::number($payload['positive_reactions_count'] ?? null), + comments: self::number($payload['comments_count'] ?? null), + readingMinutes: self::number($payload['reading_time_minutes'] ?? null), // A API devolve null em artigos sem capa — a view cai no fallback ``. coverImage: self::safeUrl($payload['cover_image'] ?? null) ?: null, tags: $tags, - authorName: (string) ($user['name'] ?? ''), - authorUsername: (string) ($user['username'] ?? ''), + authorName: self::text($user['name'] ?? null), + authorUsername: self::text($user['username'] ?? null), authorAvatar: self::safeUrl($user['profile_image_90'] ?? null), ); } @@ -69,6 +82,42 @@ public function publishedLabel(): string ->translatedFormat('M \d\e Y'); } + /** + * Texto de campo que a API promete como string. Array e objeto viram vazio em + * vez de `Array` com warning — ou de `Error` fatal, no caso de objeto sem + * `__toString`, que derrubaria a página inteira por um campo cosmético. + */ + private static function text(mixed $value): string + { + return match (true) { + is_string($value) => mb_trim($value), + is_int($value), is_float($value) => (string) $value, + default => '', + }; + } + + private static function number(mixed $value): int + { + return is_numeric($value) ? (int) $value : 0; + } + + /** + * `Carbon::parse` lança em texto livre e devolve *agora* para string vazia, então + * a data precisa ser validada antes, não interpretada com otimismo. + */ + private static function parseDate(mixed $value): ?CarbonImmutable + { + if (!is_string($value) || mb_trim($value) === '') { + return null; + } + + try { + return CarbonImmutable::parse($value); + } catch (Throwable) { + return null; + } + } + /** * O acervo é payload de terceiro e vai direto para `href`/`src`. Um `javascript:` * vindo de uma resposta adulterada viraria XSS que o escape do Blade não pega, diff --git a/app-modules/portal/src/Articles/ArticleFeed.php b/app-modules/portal/src/Articles/ArticleFeed.php index 09e9ad7d..5998b9c4 100644 --- a/app-modules/portal/src/Articles/ArticleFeed.php +++ b/app-modules/portal/src/Articles/ArticleFeed.php @@ -37,14 +37,35 @@ public function articles(): array return $this->articles; } + $payload = $this->fetch(); + $articles = []; + $rejected = 0; + + foreach ($payload as $item) { + $article = is_array($item) ? Article::fromApi($item) : null; + + if (!$article instanceof Article) { + $rejected++; - foreach ($this->fetch() as $item) { - if (!is_array($item)) { continue; } - $articles[] = Article::fromApi($item); + $articles[] = $article; + } + + if ($rejected > 0) { + Log::warning('Portal: itens do acervo do dev.to descartados por payload inválido', [ + 'descartados' => $rejected, + 'aceitos' => count($articles), + ]); + } + + // Payload com itens mas nenhum aproveitável é contrato quebrado, não acervo + // vazio. Sem descartar o cache, a janela obsoleta serviria o mesmo lixo por + // um dia inteiro — e a revalidação em segundo plano nunca o substituiria. + if ($articles === [] && $payload !== []) { + Cache::forget(self::CACHE_KEY); } usort($articles, fn (Article $a, Article $b): int => $b->publishedAt <=> $a->publishedAt); diff --git a/app-modules/portal/tests/Feature/ArticlesPageTest.php b/app-modules/portal/tests/Feature/ArticlesPageTest.php index 7d4938b2..0db97707 100644 --- a/app-modules/portal/tests/Feature/ArticlesPageTest.php +++ b/app-modules/portal/tests/Feature/ArticlesPageTest.php @@ -2,6 +2,7 @@ declare(strict_types=1); +use He4rt\Portal\Articles\Article; use He4rt\Portal\Articles\ArticleFeed; use He4rt\Portal\Livewire\ArticlesPage; use Illuminate\Support\Facades\Cache; @@ -176,3 +177,67 @@ function devToArticle(array $overrides = []): array ->assertSee('Do cache') ->assertDontSee('Não deu para carregar o acervo agora.'); }); + +it('descarta artigo com data impossível de interpretar em vez de derrubar a página', function (): void { + Http::fake([ + 'dev.to/api/articles*' => Http::response([ + devToArticle(['id' => 1, 'title' => 'Válido']), + devToArticle(['id' => 2, 'title' => 'Data podre', 'published_at' => 'amanhã cedo']), + ]), + ]); + + $articles = resolve(ArticleFeed::class)->articles(); + + expect($articles)->toHaveCount(1) + ->and($articles[0]->title)->toBe('Válido'); +}); + +it('descarta data vazia em vez de assumir hoje e jogar o artigo para o topo', function (): void { + Http::fake([ + 'dev.to/api/articles*' => Http::response([ + devToArticle(['id' => 1, 'title' => 'Real', 'published_at' => now()->subMonth()->toIso8601String()]), + devToArticle(['id' => 2, 'title' => 'Sem data', 'published_at' => '']), + ]), + ]); + + $articles = resolve(ArticleFeed::class)->articles(); + + expect($articles)->toHaveCount(1) + ->and($articles[0]->title)->toBe('Real'); +}); + +it('não quebra com campo de texto que não é texto', function (): void { + Http::fake([ + 'dev.to/api/articles*' => Http::response([ + devToArticle(['id' => 1, 'title' => ['isto' => 'é um array']]), + devToArticle(['id' => 2, 'title' => 'Sobrevivente']), + ]), + ]); + + $titles = array_map(fn (Article $article): string => $article->title, resolve(ArticleFeed::class)->articles()); + + expect($titles)->toContain('Sobrevivente'); +}); + +it('não deixa payload inteiro inválido envenenar a janela obsoleta do cache', function (): void { + Http::fake([ + 'dev.to/api/articles*' => Http::response([ + devToArticle(['id' => 1, 'published_at' => 'lixo']), + devToArticle(['id' => 2, 'published_at' => 'mais lixo']), + ]), + ]); + + expect(resolve(ArticleFeed::class)->articles())->toBeEmpty() + ->and(Cache::has('portal.articles.devto-org'))->toBeFalse(); +}); + +it('mantém a página de pé quando um item do acervo está corrompido', function (): void { + Http::fake([ + 'dev.to/api/articles*' => Http::response([ + devToArticle(['id' => 1, 'title' => 'Artigo bom']), + devToArticle(['id' => 2, 'published_at' => 'quebrado']), + ]), + ]); + + $this->get('/artigos')->assertOk()->assertSee('Artigo bom'); +});