teqo/tests/unit/youtubeFeed.unit.spec.ts
Francisco Solla a638a8acae feat(S3): seção de conteúdos da home de campanha inclui os posts do Instagram
Board "Acompanhe de perto" (S1+S2) ganha o feed automático do perfil oficial:
loader irmão src/utilities/socialFeed/instagramFeed.ts (Graph API /media com
children p/ carrossel + username, unstable_cache tag social-feed + revalidate
300, snapshot cru pré-exclusão persistido via SQL direto, refresh de token
automático com retry 1x — refresh endpoint só renova tokens de Instagram
Login, page tokens caem no fail-closed), global SocialFeedSettings estendida
(instagramEnabled/AccessToken/UserId/MaxItems + snapshot hidden; excludedItems
já cobria a plataforma desde a S2). Requisito nº 1 — picker de exclusão no
admin (primeiro componente custom do repo, campo ui + importmap): lista os
posts do snapshot com thumbnail e toggle "Não exibir" que escreve em
excludedItems. Card com coverAspect square (1:1 do rascunho), badge
Instagram, título = legenda com fallback para post sem legenda, clique no
permalink (noopener), link "Seguir no Instagram →" derivado do username.
Os feeds externos mudam para o subfolder socialFeed/ (pin da S2 previa).
Migration 20260818_192948_add_instagram_social_feed_fields (não destrutiva).
Stub IG no webServer (porta dev+2000, helper socialStub.ts) + 3 e2e novos no
describe serial + kill switch estendido + smoke do picker no admin.
Closes #20
2026-08-18 20:29:50 -03:00

266 lines
8.8 KiB
TypeScript

import { describe, expect, it } from 'vitest'
import type { YouTubeVideo } from '@/utilities/socialFeed/youtubeFeed'
import {
YOUTUBE_MAX_RESULTS_CAP,
eligibleYouTubeVideos,
formatYouTubeViews,
loadYouTubeFeed,
parseYouTubeSearchResponse,
parseYouTubeVideosResponse,
pickThumbnailUrl,
} from '@/utilities/socialFeed/youtubeFeed'
const SEARCH_ITEM = (videoId: string, title: string, publishedAt: string, extra?: object) => ({
id: { videoId },
snippet: {
publishedAt,
title,
thumbnails: {
default: { url: `https://i.ytimg.com/vi/${videoId}/default.jpg` },
high: { url: `https://i.ytimg.com/vi/${videoId}/hqdefault.jpg` },
maxres: { url: `https://i.ytimg.com/vi/${videoId}/maxresdefault.jpg` },
},
},
...extra,
})
describe('formatYouTubeViews', () => {
it('keeps raw counts below 1k', () => {
expect(formatYouTubeViews(0)).toBe('0')
expect(formatYouTubeViews(987)).toBe('987')
expect(formatYouTubeViews(999)).toBe('999')
})
it('spells thousands in pt-BR short form with one comma decimal', () => {
expect(formatYouTubeViews(1_000)).toBe('1 mil')
expect(formatYouTubeViews(12_000)).toBe('12 mil')
expect(formatYouTubeViews(12_400)).toBe('12,4 mil')
expect(formatYouTubeViews(8_100)).toBe('8,1 mil')
expect(formatYouTubeViews(999_999)).toBe('1 mi')
})
it('spells millions and billions with mi/bi', () => {
expect(formatYouTubeViews(1_000_000)).toBe('1 mi')
expect(formatYouTubeViews(1_234_567)).toBe('1,2 mi')
expect(formatYouTubeViews(12_450_000)).toBe('12,5 mi')
expect(formatYouTubeViews(1_000_000_000)).toBe('1 bi')
expect(formatYouTubeViews(1_999_999_999)).toBe('2 bi')
})
})
describe('pickThumbnailUrl', () => {
it('prefers maxres, then high, then medium', () => {
const thumbnails = {
default: { url: 'default.jpg' },
medium: { url: 'medium.jpg' },
high: { url: 'high.jpg' },
maxres: { url: 'maxres.jpg' },
}
expect(pickThumbnailUrl(thumbnails)).toBe('maxres.jpg')
expect(pickThumbnailUrl({ high: { url: 'high.jpg' }, medium: { url: 'medium.jpg' } })).toBe(
'high.jpg',
)
expect(pickThumbnailUrl({ medium: { url: 'medium.jpg' } })).toBe('medium.jpg')
})
it('reads missing or malformed thumbnails as undefined', () => {
expect(pickThumbnailUrl(undefined)).toBeUndefined()
expect(pickThumbnailUrl(null)).toBeUndefined()
expect(pickThumbnailUrl({ high: { url: 42 } })).toBeUndefined()
expect(pickThumbnailUrl({ high: { url: '' } })).toBeUndefined()
})
})
describe('parseYouTubeSearchResponse', () => {
it('parses a valid search response into videos with the best thumbnail', () => {
const videos = parseYouTubeSearchResponse({
items: [
SEARCH_ITEM('video1', 'Primeiro vídeo', '2026-08-18T10:00:00Z'),
SEARCH_ITEM('video2', 'Segundo vídeo', '2026-08-17T10:00:00Z', {
snippet: {
publishedAt: '2026-08-17T10:00:00Z',
title: 'Segundo vídeo',
thumbnails: { high: { url: 'https://i.ytimg.com/vi/video2/hqdefault.jpg' } },
},
}),
],
})
expect(videos).toHaveLength(2)
expect(videos[0]).toEqual({
id: 'video1',
title: 'Primeiro vídeo',
publishedAt: '2026-08-18T10:00:00Z',
thumbnailUrl: 'https://i.ytimg.com/vi/video1/maxresdefault.jpg',
})
expect(videos[1].thumbnailUrl).toBe('https://i.ytimg.com/vi/video2/hqdefault.jpg')
})
it('skips malformed items but throws on protocol violations', () => {
expect(
parseYouTubeSearchResponse({
items: [
{ id: { notAVideo: true } },
{ id: { videoId: 'no-title' }, snippet: { publishedAt: '2026-08-18T10:00:00Z' } },
{ id: { videoId: 'no-date' }, snippet: { title: 'Sem data' } },
SEARCH_ITEM('ok1', 'Válido', '2026-08-18T10:00:00Z'),
],
}),
).toEqual([expect.objectContaining({ id: 'ok1', title: 'Válido' })])
expect(() => parseYouTubeSearchResponse({})).toThrow()
expect(() => parseYouTubeSearchResponse(null)).toThrow()
expect(() => parseYouTubeSearchResponse({ items: 'nope' })).toThrow()
})
})
describe('parseYouTubeVideosResponse', () => {
it('maps string viewCounts to numbers', () => {
const counts = parseYouTubeVideosResponse({
items: [
{ id: 'video1', statistics: { viewCount: '12400' } },
{ id: 'video2', statistics: { viewCount: '0' } },
{ id: 'no-count', statistics: {} },
{ id: 'bad-count', statistics: { viewCount: 'abc' } },
'malformed',
{ id: 'no-stats' },
],
})
expect(counts.get('video1')).toBe(12_400)
expect(counts.get('video2')).toBe(0)
expect(counts.has('no-count')).toBe(false)
expect(counts.has('bad-count')).toBe(false)
})
it('reads missing or malformed payloads as an empty map', () => {
expect(parseYouTubeVideosResponse(undefined).size).toBe(0)
expect(parseYouTubeVideosResponse({ items: 'nope' }).size).toBe(0)
expect(parseYouTubeVideosResponse(null).size).toBe(0)
})
})
describe('eligibleYouTubeVideos', () => {
const videos: YouTubeVideo[] = [
{ id: 'a', title: 'A', publishedAt: '2026-08-18T10:00:00Z' },
{ id: 'b', title: 'B', publishedAt: '2026-08-17T10:00:00Z' },
{ id: 'c', title: 'C', publishedAt: '2026-08-16T10:00:00Z' },
{ id: 'd', title: 'D', publishedAt: '2026-08-15T10:00:00Z' },
]
it('drops excluded ids and caps at maxItems, keeping API order', () => {
expect(eligibleYouTubeVideos(videos, ['b'], 2)).toEqual([videos[0], videos[2]])
expect(eligibleYouTubeVideos(videos, [], 3)).toEqual(videos.slice(0, 3))
expect(eligibleYouTubeVideos(videos, ['a', 'b', 'c', 'd'], 5)).toEqual([])
})
})
describe('loadYouTubeFeed', () => {
const fakeResponse = (body: unknown, ok = true): Response =>
({ ok, json: async () => body }) as unknown as Response
const calls: string[] = []
const fetchImpl = async (input: string): Promise<Response> => {
calls.push(input)
if (input.includes('/search')) {
return fakeResponse({
items: [
SEARCH_ITEM('v1', 'Vídeo 1', '2026-08-18T10:00:00Z'),
SEARCH_ITEM('v2', 'Vídeo 2', '2026-08-17T10:00:00Z'),
],
})
}
return fakeResponse({
items: [
{ id: 'v1', statistics: { viewCount: '12400' } },
{ id: 'v2', statistics: { viewCount: '8100' } },
],
})
}
const args = {
apiKey: 'test-key',
channelId: 'UCtest123',
maxResults: 3,
fetchImpl,
baseUrl: 'http://localhost:4000',
}
it('fetches search then statistics and merges view counts', async () => {
calls.length = 0
const videos = await loadYouTubeFeed(args)
expect(videos).toEqual([
{
id: 'v1',
title: 'Vídeo 1',
publishedAt: '2026-08-18T10:00:00Z',
thumbnailUrl: expect.stringContaining('v1'),
viewCount: 12_400,
},
{
id: 'v2',
title: 'Vídeo 2',
publishedAt: '2026-08-17T10:00:00Z',
thumbnailUrl: expect.stringContaining('v2'),
viewCount: 8_100,
},
])
const searchCall = calls[0]
expect(searchCall).toContain('/search?')
expect(searchCall).toContain('channelId=UCtest123')
expect(searchCall).toContain('type=video')
expect(searchCall).toContain('order=date')
expect(searchCall).toContain('key=test-key')
expect(calls[1]).toContain('/videos?')
expect(calls[1]).toContain('id=v1%2Cv2')
})
it('clamps maxResults to the API cap and the minimum of 1', async () => {
calls.length = 0
await loadYouTubeFeed({ ...args, maxResults: 100 })
expect(calls[0]).toContain(`maxResults=${YOUTUBE_MAX_RESULTS_CAP}`)
calls.length = 0
await loadYouTubeFeed({ ...args, maxResults: 0 })
expect(calls[0]).toContain('maxResults=1')
})
it('returns an empty feed without calling statistics when the channel has no public videos', async () => {
calls.length = 0
const videos = await loadYouTubeFeed({
...args,
fetchImpl: async (input) => {
calls.push(input)
return fakeResponse({ items: [] })
},
})
expect(videos).toEqual([])
expect(calls).toHaveLength(1)
})
it('throws on non-2xx responses and protocol violations (fail-closed to the snapshot)', async () => {
await expect(
loadYouTubeFeed({ ...args, fetchImpl: async () => fakeResponse({}, false) }),
).rejects.toThrow()
await expect(
loadYouTubeFeed({
...args,
fetchImpl: async () => fakeResponse({ items: 'nope' }),
}),
).rejects.toThrow()
})
it('throws when the statistics call fails', async () => {
await expect(
loadYouTubeFeed({
...args,
fetchImpl: async (input) =>
input.includes('/search')
? fakeResponse({ items: [SEARCH_ITEM('v1', 'Vídeo 1', '2026-08-18T10:00:00Z')] })
: fakeResponse({}, false),
}),
).rejects.toThrow(/statistics/)
})
})