teqo/tests/e2e/youtube-stub.mjs
Francisco Solla fe5b11cf24
Some checks failed
CI (PR) / e2e (1) (pull_request) Successful in 5m9s
CI / build (push) Successful in 2m20s
CI (PR) / e2e (2) (pull_request) Successful in 5m18s
PR Ready + auto-merge / ready-automerge (pull_request) Failing after 30m17s
CI / e2e (1) (push) Successful in 4m5s
CI (PR) / scope (pull_request) Successful in 32s
CI / deploy homeserver (teqo-1313) (push) Has been cancelled
CI (PR) / lint (pull_request) Successful in 42s
CI (PR) / cycles (pull_request) Successful in 34s
CI (PR) / format (pull_request) Successful in 55s
CI (PR) / docs-guards (pull_request) Successful in 8s
CI (PR) / plans-only-closes (pull_request) Successful in 7s
Main merge flips claimed issues to done + in-prod / issue-done (pull_request) Successful in 13s
CI (PR) / typecheck (pull_request) Successful in 30s
CI (PR) / knip (pull_request) Successful in 31s
CI (PR) / unit (pull_request) Successful in 50s
CI (PR) / checks (pull_request) Successful in 2s
Promote plan-issue Related on main merge / plan-issue-ready (pull_request) Has been skipped
Agent pool supervisor / tick (pull_request) Successful in 27s
CI / static (push) Successful in 1m48s
CI / e2e (2) (push) Successful in 4m7s
CI (PR) / int (pull_request) Successful in 1m49s
CI / int (push) Successful in 1m57s
CI / checks (push) Successful in 2s
CI (PR) / build (pull_request) Successful in 2m31s
feat(S2): seção de conteúdos da home de campanha inclui os vídeos do YouTube
Board "Acompanhe de perto" (S1) ganha o feed automático do canal oficial:
global SocialFeedSettings (admin-only, kill switch + toggle + maxItems +
exclusões por item), loader src/utilities/youtubeFeed.ts (Data API v3
search→statistics, unstable_cache tag social-feed + revalidate 300,
fail-closed com snapshot persistido via SQL direto — revalidateTag dentro
de unstable_cache lança no Next 15.4), card generalizado
(CampaignArticleCard → CampaignContentCard, external com noopener) e merge
por recência com o grande = mais recente elegível. Stub HTTP do YouTube no
webServer do Playwright (porta dev+1000) + 4 e2e novos no describe serial.
Migration 20260818_111748_add_social_feed_settings (não destrutiva).
Closes #18
2026-08-18 15:23:50 -03:00

149 lines
3.8 KiB
JavaScript

/**
* Deterministic YouTube Data API v3 stub for e2e — the campaign home content
* board (S2) fetches the feed server-side, so the Next dev/prod server points
* `YOUTUBE_API_BASE_URL` here (see playwright.config.ts) and this process
* answers `search`/`videos` with a fixed fixture. `POST /__stub/state` with
* `{ "state": "ok" | "fail" }` makes the API fail (HTTP 500) so specs can
* exercise the fail-closed/snapshot paths without any network. Thumbnails are
* served locally from `/thumbs/<id>.jpg` — the sandbox never reaches
* `i.ytimg.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.YOUTUBE_STUB_PORT ?? 4000)
const CHANNEL_ID = 'UCe2eTestChannel'
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 VIDEOS = [
{
id: 'e2e-video-destaque-1',
title: 'E2e Vídeo em destaque',
minutesAgo: 30,
views: 12400,
maxres: true,
},
{
id: 'e2e-video-caravana-2',
title: 'E2e Vídeo de caravana',
minutesAgo: 120,
views: 8100,
maxres: true,
},
{
id: 'e2e-video-entrevista-3',
title: 'E2e Vídeo de entrevista',
minutesAgo: 26 * 60,
views: 987,
maxres: false,
},
{
id: 'e2e-video-excluido-4',
title: 'E2e Vídeo excluído',
minutesAgo: 10,
views: 5_000_000,
maxres: true,
},
]
const thumbnails = (video) => {
const base = `${ORIGIN}/thumbs/${video.id}.jpg`
const common = {
default: { url: base },
medium: { url: base },
high: { url: base },
}
return video.maxres ? { ...common, maxres: { url: base } } : common
}
const searchResponse = () => ({
items: VIDEOS.map((video) => ({
id: { videoId: video.id },
snippet: {
publishedAt: minutesAgo(video.minutesAgo),
channelId: CHANNEL_ID,
title: video.title,
thumbnails: thumbnails(video),
},
})),
})
const statisticsResponse = (ids) => ({
items: ids
.split(',')
.filter(Boolean)
.map((id) => {
const video = VIDEOS.find((item) => item.id === id)
return { id, statistics: { viewCount: video ? String(video.views) : '0' } }
}),
})
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 (parsed.state === 'ok' || parsed.state === 'fail') state = parsed.state
} catch {
// keep the current state
}
json(res, 200, { state })
})
return
}
if (state === 'fail') {
res.writeHead(500)
res.end('stub fail')
return
}
if (url.pathname === '/search') {
json(res, 200, searchResponse())
return
}
if (url.pathname === '/videos') {
json(res, 200, statisticsResponse(url.searchParams.get('id') ?? ''))
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(`youtube e2e stub listening on :${PORT}`)
})