teqo-backup/tests/e2e/instagram-stub.mjs
Francisco Solla 8983abc3f5 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.
2026-09-11 18:09:03 -03:00

166 lines
4.7 KiB
JavaScript

/**
* Deterministic Instagram Graph API stub for e2e — the campaign home content
* board (S3) fetches the feed server-side, so the Next dev/prod server points
* `INSTAGRAM_API_BASE_URL` here (see playwright.config.ts) and this process
* answers `/media`, `/user` and `/refresh_access_token` with a fixed fixture.
* `POST /__stub/state` with `{ "state": "ok" | "fail" | "invalid-token" }`
* switches the API: `fail` answers HTTP 500 (network/API-down paths), and
* `invalid-token` answers 400 with an OAuthException body (the S11 admin
* status panel's token-rejected copy) — so specs can exercise the
* fail-closed/snapshot paths without any network. Thumbnails are served
* locally from `/thumbs/<id>.jpg` — the sandbox never reaches
* `scontent.cdninstagram.com`, and next/image would 404 (a console error the
* e2e guard treats as a failure).
*/
import { createServer } from 'node:http'
const PORT = Number(process.env.INSTAGRAM_STUB_PORT ?? 5000)
const USER_ID = '17841400000000000'
const USERNAME = 'depjorgesolla'
const ORIGIN = `http://localhost:${PORT}`
// 1x1 transparent PNG — enough for the optimizer to proxy successfully.
const THUMBNAIL_PNG = Buffer.from(
'iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAYAAAAfFcSJAAAADUlEQVR42mP8z8BQDwAEhQGAhKmMIQAAAABJRU5ErkJggg==',
'base64',
)
const minutesAgo = (minutes) => new Date(Date.now() - minutes * 60_000).toISOString()
const POSTS = [
{
id: 'e2e-ig-muro-1',
caption: 'E2e Post do muro',
media_type: 'IMAGE',
minutesAgo: 20,
},
{
id: 'e2e-ig-reel-2',
// 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,
},
{
id: 'e2e-ig-sem-legenda-3',
caption: null,
media_type: 'IMAGE',
minutesAgo: 26 * 60,
},
{
id: 'e2e-ig-carrossel-4',
caption: 'E2e Carrossel de fotos',
media_type: 'CAROUSEL_ALBUM',
minutesAgo: 30 * 60,
},
{
id: 'e2e-ig-grade-excluido-5',
caption: 'E2e Post de grade',
media_type: 'IMAGE',
minutesAgo: 5,
},
]
const mediaItem = (post) => {
const base = `${ORIGIN}/thumbs/${post.id}.jpg`
const item = {
id: post.id,
caption: post.caption,
media_type: post.media_type,
permalink: `https://www.instagram.com/p/${post.id}/`,
timestamp: minutesAgo(post.minutesAgo),
media_url: base,
thumbnail_url: base,
}
if (post.media_type === 'CAROUSEL_ALBUM') {
item.children = { data: [{ media_url: base, thumbnail_url: base }] }
}
return item
}
const mediaResponse = () => ({ data: POSTS.map(mediaItem) })
let state = 'ok'
const json = (res, status, body) => {
res.writeHead(status, { 'content-type': 'application/json' })
res.end(JSON.stringify(body))
}
const server = createServer((req, res) => {
const url = new URL(req.url, `http://localhost:${PORT}`)
if (url.pathname === '/__stub/health') {
json(res, 200, { ok: true })
return
}
if (url.pathname === '/__stub/state' && req.method === 'POST') {
let body = ''
req.on('data', (chunk) => {
body += chunk
})
req.on('end', () => {
try {
const parsed = JSON.parse(body)
if (['ok', 'fail', 'invalid-token'].includes(parsed.state)) state = parsed.state
} catch {
// keep the current state
}
json(res, 200, { state })
})
return
}
if (state === 'invalid-token') {
// The token-rejected shape the Graph API returns for an invalid/expired
// token or one minted via Facebook Login (S11 status panel copy).
json(res, 400, {
error: {
message: 'Error validating access token: Session has expired',
type: 'OAuthException',
code: 190,
},
})
return
}
if (state === 'fail') {
res.writeHead(500)
res.end('stub fail')
return
}
if (url.pathname.endsWith('/media')) {
json(res, 200, mediaResponse())
return
}
if (url.pathname === '/refresh_access_token') {
json(res, 200, { access_token: 'e2e-refreshed-token' })
return
}
if (url.pathname.endsWith(`/${USER_ID}`) || url.pathname === '/user') {
json(res, 200, { id: USER_ID, username: USERNAME })
return
}
if (url.pathname.startsWith('/thumbs/')) {
res.writeHead(200, { 'content-type': 'image/png' })
res.end(THUMBNAIL_PNG)
return
}
res.writeHead(404)
res.end('not found')
})
server.listen(PORT, () => {
console.log(`instagram e2e stub listening on :${PORT}`)
})