teqo-backup/tests/unit/instagramFeed.unit.spec.ts
Francisco Solla cb61cf1152 feat(C211): Central de Conteúdos interna — envio em lote, link, catalogação e publicação
Envio em lote (1 request raw-body por arquivo, fan-out ≤2, até 50 arquivos) e
adicionar por link (Instagram/YouTube; extração só pela Graph API da conta
própria, senão peça-link) em /campanha/comunicacao/conteudos. Cada peça tem
estado próprio (Processando/Pronto/Falhou + Reprocessar), transcrição/texto e
catalogação automática revisável; publicar/despublicar preserva arquivo, slug
e publishedAt.

- collections contentPiece + contentMedia (upload privado no S3) e migration
  20260923_032714_add_content_piece (slug único gerado no 1º publish,
  sourceUrl única, searchText com índice trigram)
- pipeline reusa C199 (job after() + reaper + retry condicional, transcrição
  DeepInfra, ffmpeg/chunk), o classificador de facetas do acervo e o
  institutionCatalog; curatedFields impede o job de sobrescrever curadoria
- gate único canReadCommunicationCatalog (advisor/leader negados); delete
  só pelo admin; sem Consent novo
- UI portada do design aprovado (lista desktop + cards mobile, diálogo de
  lote com progresso por arquivo, diálogo de link, ficha editável, kill
  switch); nav/chrome/column-picker/manifest atualizados
- instagramFeed expõe mediaUrl; revalidateTag ganha wrapper seguro fora de
  request (fixtures/scripts)
- testes: 2 unit specs, 1 int spec (16 casos) e 7 e2e na vertical
- follow-up de engenharia: #1267 (docs/plans/escala-dry-pos-c211.md)

Closes #1254
2026-09-23 02:25:15 -03:00

415 lines
13 KiB
TypeScript

import { describe, expect, it } from 'vitest'
import type { InstagramPost } from '@/utilities/socialFeed/instagramFeed'
import {
INSTAGRAM_MAX_RESULTS_CAP,
eligibleInstagramPosts,
loadInstagramFeed,
parseInstagramMediaResponse,
pickInstagramThumbnail,
} from '@/utilities/socialFeed/instagramFeed'
const MEDIA_ITEM = (
id: string,
overrides: Record<string, unknown> = {},
): Record<string, unknown> => ({
id,
caption: `Legenda ${id}`,
media_type: 'IMAGE',
media_url: `https://scontent.cdninstagram.com/${id}.jpg`,
permalink: `https://www.instagram.com/p/${id}/`,
timestamp: '2026-08-18T10:00:00+00:00',
...overrides,
})
describe('pickInstagramThumbnail', () => {
it('uses the media URL for images', () => {
expect(pickInstagramThumbnail(MEDIA_ITEM('img'))).toBe(
'https://scontent.cdninstagram.com/img.jpg',
)
})
it('uses the thumbnail for videos and reels, never the mp4', () => {
expect(
pickInstagramThumbnail(
MEDIA_ITEM('vid', {
media_type: 'VIDEO',
media_url: 'https://scontent.cdninstagram.com/vid.mp4',
thumbnail_url: 'https://scontent.cdninstagram.com/vid.jpg',
}),
),
).toBe('https://scontent.cdninstagram.com/vid.jpg')
expect(
pickInstagramThumbnail(
MEDIA_ITEM('reel', {
media_type: 'REEL',
thumbnail_url: 'https://scontent.cdninstagram.com/reel.jpg',
}),
),
).toBe('https://scontent.cdninstagram.com/reel.jpg')
})
it('resolves carousels from the first child that carries a URL', () => {
expect(
pickInstagramThumbnail(
MEDIA_ITEM('car', {
media_type: 'CAROUSEL_ALBUM',
children: {
data: [
{ media_url: 'https://scontent.cdninstagram.com/first.jpg' },
{ media_url: 'https://scontent.cdninstagram.com/second.jpg' },
],
},
}),
),
).toBe('https://scontent.cdninstagram.com/first.jpg')
expect(
pickInstagramThumbnail(
MEDIA_ITEM('car2', {
media_type: 'CAROUSEL_ALBUM',
children: {
data: [
{ thumbnail_url: 'https://scontent.cdninstagram.com/child-video.jpg' },
{ media_url: 'https://scontent.cdninstagram.com/child-image.jpg' },
],
},
}),
),
).toBe('https://scontent.cdninstagram.com/child-video.jpg')
})
it('reads missing or malformed thumbnails as undefined', () => {
expect(
pickInstagramThumbnail(
MEDIA_ITEM('no-thumb', { media_type: 'REEL', thumbnail_url: undefined }),
),
).toBeUndefined()
expect(
pickInstagramThumbnail(
MEDIA_ITEM('empty-children', { media_type: 'CAROUSEL_ALBUM', children: { data: [] } }),
),
).toBeUndefined()
expect(
pickInstagramThumbnail(
MEDIA_ITEM('bad-children', {
media_type: 'CAROUSEL_ALBUM',
children: { data: ['nope'] },
}),
),
).toBeUndefined()
expect(
pickInstagramThumbnail(
MEDIA_ITEM('no-type', { media_type: undefined, media_url: undefined }),
),
).toBeUndefined()
})
})
describe('parseInstagramMediaResponse', () => {
it('parses a valid media response, keeping null captions', () => {
const posts = parseInstagramMediaResponse({
data: [
MEDIA_ITEM('post1'),
MEDIA_ITEM('post2', { caption: null, media_type: 'REEL', thumbnail_url: 'thumb.jpg' }),
MEDIA_ITEM('post3', {
media_type: 'CAROUSEL_ALBUM',
children: { data: [{ media_url: 'child.jpg' }] },
}),
],
})
expect(posts).toHaveLength(3)
expect(posts[0]).toEqual({
id: 'post1',
caption: 'Legenda post1',
mediaType: 'IMAGE',
permalink: 'https://www.instagram.com/p/post1/',
mediaUrl: 'https://scontent.cdninstagram.com/post1.jpg',
thumbnailUrl: 'https://scontent.cdninstagram.com/post1.jpg',
timestamp: '2026-08-18T10:00:00+00:00',
})
expect(posts[1].caption).toBeNull()
expect(posts[1].thumbnailUrl).toBe('thumb.jpg')
expect(posts[2].thumbnailUrl).toBe('child.jpg')
})
it('drops items without id, permalink or timestamp but throws on protocol violations', () => {
expect(
parseInstagramMediaResponse({
data: [
MEDIA_ITEM('ok'),
{ ...MEDIA_ITEM('no-permalink'), permalink: undefined },
{ ...MEDIA_ITEM('no-timestamp'), timestamp: undefined },
{ id: 'no-caption', permalink: 'https://instagram.com/p/x/', timestamp: 't' },
'malformed',
],
}),
).toEqual([
expect.objectContaining({ id: 'ok' }),
expect.objectContaining({ id: 'no-caption', caption: null }),
])
expect(() => parseInstagramMediaResponse({})).toThrow()
expect(() => parseInstagramMediaResponse(null)).toThrow()
expect(() => parseInstagramMediaResponse({ data: 'nope' })).toThrow()
})
})
describe('eligibleInstagramPosts', () => {
const posts: InstagramPost[] = [
{
id: 'a',
caption: 'A',
mediaType: 'IMAGE',
permalink: 'https://instagram.com/p/a/',
mediaUrl: null,
timestamp: 't1',
},
{
id: 'b',
caption: 'B',
mediaType: 'IMAGE',
permalink: 'https://instagram.com/p/b/',
mediaUrl: null,
timestamp: 't2',
},
{
id: 'c',
caption: 'C',
mediaType: 'IMAGE',
permalink: 'https://instagram.com/p/c/',
mediaUrl: null,
timestamp: 't3',
},
{
id: 'd',
caption: 'D',
mediaType: 'IMAGE',
permalink: 'https://instagram.com/p/d/',
mediaUrl: null,
timestamp: 't4',
},
]
it('drops excluded ids and caps at maxItems, keeping API order', () => {
expect(eligibleInstagramPosts(posts, ['b'], 2)).toEqual([posts[0], posts[2]])
expect(eligibleInstagramPosts(posts, [], 3)).toEqual(posts.slice(0, 3))
expect(eligibleInstagramPosts(posts, ['a', 'b', 'c', 'd'], 5)).toEqual([])
})
})
describe('loadInstagramFeed', () => {
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('/media')) {
return fakeResponse({
data: [
MEDIA_ITEM('post1', { caption: 'Post 1' }),
MEDIA_ITEM('post2', { caption: 'Post 2' }),
],
})
}
if (input.includes('/refresh_access_token')) {
return fakeResponse({ access_token: 'refreshed-token' })
}
return fakeResponse({ username: 'depjorgesolla' })
}
const args = {
accessToken: 'test-token',
userId: '17841400000000000',
maxResults: 3,
fetchImpl,
baseUrl: 'http://localhost:5000',
}
it('fetches user then media and resolves the thumbnail per type', async () => {
calls.length = 0
const result = await loadInstagramFeed(args)
expect(result.username).toBe('depjorgesolla')
expect(result.refreshedAccessToken).toBeUndefined()
expect(result.posts).toEqual([
expect.objectContaining({ id: 'post1', caption: 'Post 1' }),
expect.objectContaining({ id: 'post2' }),
])
const userCall = calls[0]
expect(userCall).toContain(`/17841400000000000?`)
expect(userCall).toContain('fields=username')
expect(userCall).toContain(`access_token=test-token`)
expect(calls[1]).toContain('/media?')
expect(calls[1]).toContain('fields=id%2Ccaption%2Cmedia_type%2Cmedia_url')
expect(calls[1]).toContain('children%7Bmedia_url%2Cthumbnail_url%7D')
expect(calls[1]).toContain('limit=3')
})
it('clamps maxResults to the API cap and the minimum of 1', async () => {
calls.length = 0
await loadInstagramFeed({ ...args, maxResults: 100 })
expect(calls[1]).toContain(`limit=${INSTAGRAM_MAX_RESULTS_CAP}`)
calls.length = 0
await loadInstagramFeed({ ...args, maxResults: 0 })
expect(calls[1]).toContain('limit=1')
})
it('refreshes the token once and retries when the media call fails', async () => {
calls.length = 0
let mediaFailures = 1
const result = await loadInstagramFeed({
...args,
fetchImpl: async (input) => {
calls.push(input)
if (input.includes('/media')) {
if (mediaFailures > 0) {
mediaFailures -= 1
return fakeResponse({ error: { code: 190 } }, false)
}
return fakeResponse({ data: [MEDIA_ITEM('post-retry')] })
}
if (input.includes('/refresh_access_token')) {
return fakeResponse({ access_token: 'refreshed-token' })
}
return fakeResponse({ username: 'depjorgesolla' })
},
})
expect(result.refreshedAccessToken).toBe('refreshed-token')
expect(result.posts).toEqual([expect.objectContaining({ id: 'post-retry' })])
const refreshCall = calls.find((call) => call.includes('/refresh_access_token'))
expect(calls.filter((call) => call.includes('/refresh_access_token'))).toHaveLength(1)
expect(calls.filter((call) => call.includes('/media'))).toHaveLength(2)
expect(refreshCall).toContain('grant_type=ig_refresh_token')
expect(refreshCall).toContain('access_token=test-token')
})
it('throws when the refresh fails or returns no token (fail-closed to the snapshot)', async () => {
await expect(
loadInstagramFeed({
...args,
fetchImpl: async () => fakeResponse({}, false),
}),
).rejects.toThrow()
await expect(
loadInstagramFeed({
...args,
fetchImpl: async (input) =>
input.includes('/media') ? fakeResponse({}, false) : fakeResponse({}, true),
}),
).rejects.toThrow(/refresh/)
await expect(
loadInstagramFeed({
...args,
fetchImpl: async (input) =>
input.includes('/media') ? fakeResponse({}, false) : fakeResponse({ nope: true }),
}),
).rejects.toThrow(/sem novo token/)
})
it('throws on protocol violations and a failed retry', async () => {
await expect(
loadInstagramFeed({
...args,
fetchImpl: async () => fakeResponse({ data: 'nope' }),
}),
).rejects.toThrow()
await expect(
loadInstagramFeed({
...args,
fetchImpl: async (input) =>
input.includes('/media') ? fakeResponse({}, false) : fakeResponse({}, false),
}),
).rejects.toThrow()
})
it('reads a missing or malformed username as null', async () => {
const result = await loadInstagramFeed({
...args,
fetchImpl: async (input) =>
input.includes('/media') ? fakeResponse({ data: [MEDIA_ITEM('p')] }) : fakeResponse({}),
})
expect(result.username).toBeNull()
})
it('throws an InstagramApiError carrying the parsed Graph API error body', async () => {
const oauthError = {
error: {
message: 'Error validating access token: Session has expired',
type: 'OAuthException',
code: 190,
},
}
const userCall = loadInstagramFeed({
...args,
fetchImpl: async (input) =>
input.includes('/media') ? fakeResponse({}, false) : fakeResponse(oauthError, false),
})
await expect(userCall).rejects.toMatchObject({
name: 'InstagramApiError',
status: undefined,
apiMessage: 'Error validating access token: Session has expired',
apiType: 'OAuthException',
})
// A refresh rejected with the same OAuth body surfaces the typed error too
// (the FB-page-token case: refresh fails → the panel must say why).
const refreshRejectCall = loadInstagramFeed({
...args,
fetchImpl: async (input) =>
input.includes('/refresh_access_token')
? fakeResponse(oauthError, false)
: fakeResponse({}, false),
})
await expect(refreshRejectCall).rejects.toMatchObject({ apiType: 'OAuthException' })
})
it('survives a non-JSON error body (status kept, api message null)', async () => {
const errorResponse = {
ok: false,
status: 502,
json: async () => {
throw new Error('boom')
},
} as unknown as Response
await expect(
loadInstagramFeed({
...args,
fetchImpl: async () => errorResponse,
}),
).rejects.toMatchObject({
name: 'InstagramApiError',
status: 502,
apiMessage: null,
apiType: null,
})
})
it('forwards the abort signal to every fetch', async () => {
const receivedInits: RequestInit[] = []
const fetchImpl = async (input: string, init?: RequestInit): Promise<Response> => {
receivedInits.push(init ?? {})
if (input.includes('/media')) {
return fakeResponse({ data: [MEDIA_ITEM('p')] })
}
if (input.includes('/refresh_access_token')) {
return fakeResponse({ access_token: 't' })
}
return fakeResponse({ username: 'depjorgesolla' })
}
const controller = new AbortController()
await loadInstagramFeed({ ...args, fetchImpl, signal: controller.signal })
expect(receivedInits).toHaveLength(2)
for (const init of receivedInits) {
expect(init.signal).toBe(controller.signal)
}
})
})