mirror of
https://github.com/fsolla/teqo.git
synced 2026-11-13 15:13:07 +00:00
fix(social-feed): capas do Instagram voltam a carregar e legendas longas não esticam o card
As mídias da Graph API passaram a vir de *.fbcdn.net (instagram.<pop>.fna.fbcdn.net), host fora do images.remotePatterns — o otimizador respondia 400 e toda capa do Instagram quebrava. O pattern em new URL() também pina search:'' e rejeitaria as URLs assinadas mesmo com o host certo; agora são objetos planos (cdninstagram + fbcdn). O título do card ganha line-clamp-3 (subtítulo, line-clamp-2) para a legenda do Instagram não esticar o bento. Testes: guard unitário do allowlist com URLs assinadas reais casadas pelo matcher do próprio Next + seam parser→allowlist; e2e com legenda longa no stub e asserção de -webkit-line-clamp; manifest de e2e afetado passa a mapear o board de conteúdos para o spec frontend.
This commit is contained in:
parent
e481819d15
commit
a83b6dd50e
5 changed files with 93 additions and 5 deletions
|
|
@ -14,8 +14,15 @@ const nextConfig = {
|
|||
new URL('https://i.ytimg.com/**'),
|
||||
// Instagram media thumbnails (campaign home content board, S3) come from
|
||||
// the Graph API's CDN (hosts vary per region) —
|
||||
// see `src/utilities/socialFeed/instagramFeed.ts`.
|
||||
new URL('https://*.cdninstagram.com/**'),
|
||||
// see `src/utilities/socialFeed/instagramFeed.ts`. Plain objects (not
|
||||
// `new URL`) on purpose: the URL form pins `search: ''`, and Graph API
|
||||
// media URLs carry a signed query string — the optimizer would reject
|
||||
// them with 400 even on an allowed host.
|
||||
{ protocol: 'https', hostname: '*.cdninstagram.com', pathname: '/**' },
|
||||
// Meta moved Instagram Graph API media to `*.fbcdn.net`
|
||||
// (`instagram.<pop>.fna.fbcdn.net`, `scontent.<pop>.fna.fbcdn.net`) —
|
||||
// without this the optimizer answers 400 and every IG cover breaks.
|
||||
{ protocol: 'https', hostname: '**.fbcdn.net', pathname: '/**' },
|
||||
// e2e-only: the YouTube/Instagram stubs (tests/e2e/*-stub.mjs) serve the
|
||||
// fixture thumbnails locally so specs never touch the real network.
|
||||
{ protocol: 'http', hostname: 'localhost', pathname: '/thumbs/**' },
|
||||
|
|
|
|||
|
|
@ -56,14 +56,16 @@ export const CampaignContentCard = ({ card, featured = false }: CampaignContentC
|
|||
</div>
|
||||
<div className="flex flex-1 flex-col gap-1 p-3">
|
||||
<h3
|
||||
className={`leading-snug font-bold text-(--campaign-ink) group-hover:underline ${
|
||||
className={`line-clamp-3 leading-snug font-bold text-(--campaign-ink) group-hover:underline ${
|
||||
featured ? 'text-base' : 'text-sm'
|
||||
}`}
|
||||
>
|
||||
{card.title}
|
||||
</h3>
|
||||
{featured && card.subtitle ? (
|
||||
<p className="text-xs leading-relaxed text-(--campaign-muted)">{card.subtitle}</p>
|
||||
<p className="line-clamp-2 text-xs leading-relaxed text-(--campaign-muted)">
|
||||
{card.subtitle}
|
||||
</p>
|
||||
) : null}
|
||||
<span className="mt-auto text-xs text-(--campaign-muted)">{card.meta}</span>
|
||||
</div>
|
||||
|
|
|
|||
|
|
@ -1263,6 +1263,15 @@ test.describe('Campaign home content section', () => {
|
|||
// Instagram cards render the 1:1 cover of the approved draft.
|
||||
await expect(featured.locator('.aspect-square').first()).toBeVisible()
|
||||
|
||||
// Long captions are clamped with an ellipsis so a single post cannot
|
||||
// stretch the bento row (production captions run into hundreds of
|
||||
// characters; the stub's reel caption is deliberately long).
|
||||
const longCaptionTitle = section
|
||||
.getByRole('link', { name: /E2e Reel da caravana/ })
|
||||
.locator('h3')
|
||||
await expect(longCaptionTitle).toHaveCSS('-webkit-line-clamp', '3')
|
||||
await expect(longCaptionTitle).toHaveCSS('overflow', 'hidden')
|
||||
|
||||
await expect(
|
||||
section.getByRole('link', { name: /E2e Artigo no mix IG/ }).first(),
|
||||
).toBeVisible()
|
||||
|
|
|
|||
|
|
@ -36,7 +36,13 @@ const POSTS = [
|
|||
},
|
||||
{
|
||||
id: 'e2e-ig-reel-2',
|
||||
caption: 'E2e Reel da caravana',
|
||||
// Deliberately long: the card must clamp the caption (line-clamp) instead
|
||||
// of stretching the bento row — production captions run into hundreds of
|
||||
// characters.
|
||||
caption:
|
||||
'E2e Reel da caravana — legenda longa de propósito para o card não esticar. ' +
|
||||
'A caminhada segue pelo interior da Bahia, com caravanas, encontros, ' +
|
||||
'reuniões e muito trabalho de base em cada município visitado. '.repeat(4),
|
||||
media_type: 'REEL',
|
||||
minutesAgo: 90,
|
||||
},
|
||||
|
|
|
|||
64
tests/unit/nextImageRemotePatterns.unit.spec.ts
Normal file
64
tests/unit/nextImageRemotePatterns.unit.spec.ts
Normal file
|
|
@ -0,0 +1,64 @@
|
|||
// @vitest-environment node
|
||||
|
||||
import { matchRemotePattern } from 'next/dist/shared/lib/match-remote-pattern'
|
||||
import { describe, expect, it } from 'vitest'
|
||||
|
||||
import { parseInstagramMediaResponse } from '@/utilities/socialFeed/instagramFeed'
|
||||
|
||||
import nextConfig from '../../next.config.mjs'
|
||||
|
||||
/**
|
||||
* Regression guard for the production incident of 2026-09-11: Instagram Graph
|
||||
* API media URLs come from Meta's CDN on `*.fbcdn.net`
|
||||
* (`instagram.<pop>.fna.fbcdn.net`, `scontent.<pop>.fna.fbcdn.net`), not only
|
||||
* from `*.cdninstagram.com` as the S3 implementation assumed. A media URL
|
||||
* outside `images.remotePatterns` makes the Next image optimizer answer
|
||||
* `400 "url" parameter is not allowed` and every Instagram card renders with a
|
||||
* broken cover. The e2e Instagram stub serves thumbnails from localhost (the
|
||||
* sandbox never reaches the real CDN), so it can never catch a missing real
|
||||
* host — this guard pins the allowlist against the hosts production serves.
|
||||
*
|
||||
* The fixtures carry the signed query string (`?stp=...&_nc_ohc=...&oe=...`)
|
||||
* the Graph API actually returns: a pattern declared with `new URL()` pins
|
||||
* `search: ''` and the matcher then rejects every signed URL even on an
|
||||
* allowed host.
|
||||
*/
|
||||
const remotePatterns = nextConfig.images?.remotePatterns ?? []
|
||||
|
||||
const isAllowed = (url: string): boolean =>
|
||||
remotePatterns.some((pattern) => matchRemotePattern(pattern, new URL(url)))
|
||||
|
||||
describe('next.config images.remotePatterns', () => {
|
||||
it.each([
|
||||
'https://instagram.fssa2-1.fna.fbcdn.net/v/t51.82787-15/798590021_n.jpg?stp=dst-jpg_e35_tt6&_nc_ohc=GhQvEtCG8Bk&oe=6AAA3F4E',
|
||||
'https://scontent.fsdu2-1.fna.fbcdn.net/v/t51.82787-15/798590021_n.jpg?stp=dst-jpg_e35_tt6&_nc_ohc=GhQvEtCG8Bk&oe=6AAA3F4E',
|
||||
'https://scontent.cdninstagram.com/v/t51.82787-15/798590021_n.jpg?stp=dst-jpg_e35_tt6&_nc_ohc=GhQvEtCG8Bk&oe=6AAA3F4E',
|
||||
'https://i.ytimg.com/vi/e2evideo001/hqdefault.jpg',
|
||||
])('allows the real media CDN URL %s', (url) => {
|
||||
expect(isAllowed(url)).toBe(true)
|
||||
})
|
||||
|
||||
it('keeps unrelated hosts out of the optimizer allowlist', () => {
|
||||
expect(isAllowed('https://evil.example.com/tracker.jpg')).toBe(false)
|
||||
})
|
||||
|
||||
it('allows the thumbnail the Instagram parser emits from a real signed response', () => {
|
||||
const signed =
|
||||
'https://instagram.fssa2-1.fna.fbcdn.net/v/t51.82787-15/798590021_n.jpg?stp=dst-jpg_e35_tt6&_nc_ohc=GhQvEtCG8Bk&oe=6AAA3F4E'
|
||||
const posts = parseInstagramMediaResponse({
|
||||
data: [
|
||||
{
|
||||
id: 'seam-1',
|
||||
caption: null,
|
||||
media_type: 'IMAGE',
|
||||
media_url: signed,
|
||||
permalink: 'https://www.instagram.com/p/seam-1/',
|
||||
timestamp: '2026-09-11T10:00:00+00:00',
|
||||
},
|
||||
],
|
||||
})
|
||||
const thumbnailUrl = posts[0]?.thumbnailUrl
|
||||
expect(thumbnailUrl).toBe(signed)
|
||||
expect(isAllowed(thumbnailUrl as string)).toBe(true)
|
||||
})
|
||||
})
|
||||
Loading…
Reference in a new issue