teqo-backup/tests/unit/contentPiece.unit.spec.ts
Francisco Solla 4a07bac596 feat(C230): importar publicações do perfil oficial na Central
Lê a janela recente (12 mídias) de @depjorgesolla pela Graph API oficial e
cria Rascunho só para as novidades pelo pipeline C211/C220 (ou peça-link com
motivo honesto), com progresso real e recibo novas/já estavam/falharam.
Dedupe pela identidade do post (shortcode em qualquer grafia p/reel/tv), sem
migration, compartilhado com o Adicionar por link; fail-closed de credencial
nas duas pontas; nada é publicado.

Closes #1362
2026-09-26 02:06:17 -03:00

465 lines
18 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

// @vitest-environment node
import { describe, expect, it } from 'vitest'
import {
canRetryContentPiece,
contentPieceFailureMessage,
contentPieceIsPublic,
contentPieceLinkFailureReasonLabels,
contentPieceLinkTitle,
contentPieceOriginFromLink,
contentPiecePostIdentityUrls,
contentPieceProfileCandidateFromPost,
contentPieceSearchText,
contentPieceSlugBase,
contentPieceSlugCandidates,
contentPieceTitleFromFilename,
contentPieceTypeFromMime,
isContentPieceCuratedField,
isContentPieceLinkFailureReason,
isContentPieceOrigin,
isContentPieceProcessingStatus,
isContentPieceStatus,
isContentPieceStep,
isContentPieceTopic,
isContentPieceType,
needsContentPieceProcessing,
parseContentPieceLink,
sanitizeContentPieceFilename,
toContentPieceViewModel,
} from '@/lib/contentPiece'
import { contentPieceUpdateRequestSchema } from '@/lib/schemas/contentPiece'
import {
buildContentPieceListWhere,
parseContentPieceListParams,
resolveContentPieceListUrl,
} from '@/utilities/content/contentPieceListUrl'
describe('content piece vocabulary', () => {
it('guards every persisted enum', () => {
expect(isContentPieceType('video')).toBe(true)
expect(isContentPieceType('card')).toBe(true)
expect(isContentPieceType('reels')).toBe(false)
expect(isContentPieceStatus('publicado')).toBe(true)
expect(isContentPieceStatus('published')).toBe(false)
expect(isContentPieceProcessingStatus('falhou')).toBe(true)
expect(isContentPieceProcessingStatus('failed')).toBe(false)
expect(isContentPieceStep('catalogando')).toBe(true)
expect(isContentPieceStep('extracting')).toBe(false)
expect(isContentPieceCuratedField('transcript')).toBe(true)
expect(isContentPieceCuratedField('slug')).toBe(false)
expect(isContentPieceOrigin('arquivo')).toBe(true)
expect(isContentPieceOrigin('link')).toBe(false)
expect(isContentPieceLinkFailureReason('carrossel')).toBe(true)
expect(isContentPieceLinkFailureReason('sem-credencial')).toBe(true)
expect(isContentPieceLinkFailureReason('falhou')).toBe(false)
expect(isContentPieceTopic('saude')).toBe(true)
expect(isContentPieceTopic('nao-existe')).toBe(false)
})
it('accepts a long transcript in the update schema', () => {
const transcript = 'texto da peça '.repeat(25_000)
expect(() =>
contentPieceUpdateRequestSchema.parse({
contentPieceId: 1,
title: 'Peça longa',
type: 'video',
transcript,
}),
).not.toThrow()
})
it('labels the four peça-link reasons verbatim (gate copy)', () => {
expect(contentPieceLinkFailureReasonLabels).toEqual({
'nao-encontrado': 'Link não encontrado entre as mídias recentes do perfil',
carrossel: 'Carrossel: sem arquivo único para baixar',
indisponivel: 'Instagram indisponível no momento',
'sem-credencial': 'Sem credencial do Instagram configurada',
})
})
it('knows which types need the pipeline', () => {
expect(needsContentPieceProcessing('video')).toBe(true)
expect(needsContentPieceProcessing('audio')).toBe(true)
expect(needsContentPieceProcessing('texto')).toBe(true)
expect(needsContentPieceProcessing('foto')).toBe(false)
expect(needsContentPieceProcessing('card')).toBe(false)
})
it('retries only a failed piece', () => {
expect(canRetryContentPiece('falhou')).toBe(true)
expect(canRetryContentPiece('pronto')).toBe(false)
expect(canRetryContentPiece('processando')).toBe(false)
})
it('is public only when published with something to show', () => {
expect(contentPieceIsPublic({ status: 'publicado', hasFile: true, sourceUrl: null })).toBe(true)
expect(
contentPieceIsPublic({ status: 'publicado', hasFile: false, sourceUrl: 'https://x/y' }),
).toBe(true)
expect(contentPieceIsPublic({ status: 'publicado', hasFile: false, sourceUrl: null })).toBe(
false,
)
expect(contentPieceIsPublic({ status: 'rascunho', hasFile: true, sourceUrl: null })).toBe(false)
})
it('maps the stored failure to honest copy by step', () => {
expect(contentPieceFailureMessage({ error: null })).toBeNull()
expect(contentPieceFailureMessage({ error: 'boom', step: 'transcrevendo' })).toBe(
'Não foi possível transcrever a peça.',
)
expect(contentPieceFailureMessage({ error: 'boom', step: 'catalogando' })).toBe(
'Não foi possível catalogar a peça.',
)
expect(
contentPieceFailureMessage({
error: 'O processamento foi interrompido antes de terminar.',
step: 'salvando',
}),
).toBe('O processamento foi interrompido antes de terminar.')
})
})
describe('contentPieceTypeFromMime', () => {
it('prefers the MIME family', () => {
expect(contentPieceTypeFromMime('video/mp4', 'x.mp4')).toBe('video')
expect(contentPieceTypeFromMime('audio/mpeg', 'x.mp3')).toBe('audio')
expect(contentPieceTypeFromMime('text/plain', 'x.txt')).toBe('texto')
expect(contentPieceTypeFromMime('image/png', 'x.png')).toBe('foto')
})
it('falls back to the extension when the OS sends no type', () => {
expect(contentPieceTypeFromMime('', 'fala.mkv')).toBe('video')
expect(contentPieceTypeFromMime(undefined, 'audio-grupo.wav')).toBe('audio')
expect(contentPieceTypeFromMime(null, 'mensagem.md')).toBe('texto')
expect(contentPieceTypeFromMime('', 'card-feira.jpg')).toBe('foto')
})
it('refuses what the catalogue does not read', () => {
expect(contentPieceTypeFromMime('application/pdf', 'plano.pdf')).toBeNull()
expect(contentPieceTypeFromMime('', 'arquivo.exe')).toBeNull()
expect(contentPieceTypeFromMime(null, undefined)).toBeNull()
})
})
describe('filename helpers', () => {
it('derives a human title from the filename', () => {
expect(contentPieceTitleFromFilename('fim-da_escala-6x1.mp4')).toBe('fim da escala 6x1')
expect(contentPieceTitleFromFilename('C:\\midia\\card feira.png')).toBe('card feira')
expect(contentPieceTitleFromFilename('.mp4')).toBe('Nova peça')
})
it('sanitizes the stored filename', () => {
expect(sanitizeContentPieceFilename('../../etc/passwd')).toBe('passwd')
expect(sanitizeContentPieceFilename('fala sus (1).mp4')).toBe('fala_sus_1_.mp4')
expect(sanitizeContentPieceFilename('')).toBe('peca')
})
it('builds the slug base and its candidates', () => {
expect(contentPieceSlugBase('Fim da escala 6×1 é saúde!')).toBe('fim-da-escala-6-1-e-saude')
expect(contentPieceSlugCandidates('Fim da escala 6×1 é saúde!').slice(0, 3)).toEqual([
'fim-da-escala-6-1-e-saude',
'fim-da-escala-6-1-e-saude-2',
'fim-da-escala-6-1-e-saude-3',
])
expect(contentPieceSlugCandidates('!!!', 2)).toEqual(['peca', 'peca-2'])
})
})
describe('content piece links', () => {
it('canonicalizes Instagram forms and drops tracking', () => {
expect(parseContentPieceLink('https://www.instagram.com/p/ABC123/?igsh=xyz')).toEqual({
origin: 'instagram',
shortcode: 'ABC123',
canonicalUrl: 'https://www.instagram.com/p/ABC123/',
})
expect(parseContentPieceLink('https://instagram.com/reels/ABC123')).toEqual({
origin: 'instagram',
shortcode: 'ABC123',
canonicalUrl: 'https://www.instagram.com/reel/ABC123/',
})
expect(parseContentPieceLink('https://m.instagram.com/tv/ABC123/')?.origin).toBe('instagram')
})
it('accepts the profile-prefixed spelling the browser copies', () => {
expect(parseContentPieceLink('https://www.instagram.com/depjorgesolla/reel/ABC123/')).toEqual({
origin: 'instagram',
shortcode: 'ABC123',
canonicalUrl: 'https://www.instagram.com/reel/ABC123/',
})
expect(
parseContentPieceLink('https://m.instagram.com/depjorgesolla/p/ABC123/?igsh=xyz'),
).toEqual({
origin: 'instagram',
shortcode: 'ABC123',
canonicalUrl: 'https://www.instagram.com/p/ABC123/',
})
})
it('refuses stories and malformed prefixed paths', () => {
expect(parseContentPieceLink('https://www.instagram.com/stories/depjorgesolla/123/')).toBeNull()
expect(parseContentPieceLink('https://www.instagram.com/stories/reel/ABC123/')).toBeNull()
expect(parseContentPieceLink('https://www.instagram.com/depjorgesolla/ABC123/')).toBeNull()
expect(
parseContentPieceLink('https://www.instagram.com/depjorgesolla/reel/ABC123/extra/'),
).toBeNull()
expect(parseContentPieceLink('https://instagr.am/reel/ABC123/')).toBeNull()
})
it('canonicalizes YouTube forms to the watch URL', () => {
expect(parseContentPieceLink('https://youtu.be/VIDEO1?t=10')).toEqual({
origin: 'youtube',
videoId: 'VIDEO1',
canonicalUrl: 'https://www.youtube.com/watch?v=VIDEO1',
})
expect(parseContentPieceLink('https://www.youtube.com/shorts/VIDEO2')).toEqual({
origin: 'youtube',
videoId: 'VIDEO2',
canonicalUrl: 'https://www.youtube.com/watch?v=VIDEO2',
})
const watch = parseContentPieceLink('https://www.youtube.com/watch?v=VIDEO3&utm_source=x')
expect(watch?.origin === 'youtube' ? watch.videoId : null).toBe('VIDEO3')
})
it('refuses other hosts and malformed input', () => {
expect(parseContentPieceLink('https://twitter.com/user/status/1')).toBeNull()
expect(parseContentPieceLink('https://www.instagram.com/')).toBeNull()
expect(parseContentPieceLink('https://www.instagram.com/p/')).toBeNull()
expect(parseContentPieceLink('nao e link')).toBeNull()
expect(parseContentPieceLink('')).toBeNull()
expect(contentPieceOriginFromLink('https://youtu.be/VIDEO1')).toBe('youtube')
expect(contentPieceOriginFromLink('https://example.com/x')).toBeNull()
})
it('gives a link a provisional title', () => {
const instagram = parseContentPieceLink('https://www.instagram.com/reel/ABC/')!
expect(contentPieceLinkTitle(instagram)).toBe('Instagram · ABC')
const youtube = parseContentPieceLink('https://youtu.be/VID')!
expect(contentPieceLinkTitle(youtube)).toBe('YouTube · VID')
})
})
describe('C230 — the identity and the import candidate of a profile post', () => {
it('spells every Instagram kind because the shortcode is the identity', () => {
const fromPost = parseContentPieceLink('https://www.instagram.com/p/ABC123/')!
const fromReel = parseContentPieceLink('https://www.instagram.com/reel/ABC123/')!
// The kind is presentation: both pastes probe the same three spellings.
expect(contentPiecePostIdentityUrls(fromPost)).toEqual([
'https://www.instagram.com/p/ABC123/',
'https://www.instagram.com/reel/ABC123/',
'https://www.instagram.com/tv/ABC123/',
])
expect(contentPiecePostIdentityUrls(fromReel)).toEqual(contentPiecePostIdentityUrls(fromPost))
})
it('keeps the single canonical URL for YouTube', () => {
const youtube = parseContentPieceLink('https://youtu.be/VIDEO1')!
expect(contentPiecePostIdentityUrls(youtube)).toEqual([
'https://www.youtube.com/watch?v=VIDEO1',
])
})
it('classifies the feed metadata into the honest link-only reason', () => {
expect(
contentPieceProfileCandidateFromPost({
permalink: 'https://www.instagram.com/reel/ABC/',
mediaType: 'REEL',
mediaUrl: 'https://cdn.example/reel.mp4',
}),
).toEqual({ url: 'https://www.instagram.com/reel/ABC/', linkOnlyReason: null })
expect(
contentPieceProfileCandidateFromPost({
permalink: 'https://www.instagram.com/p/ABC/',
mediaType: 'IMAGE',
mediaUrl: 'https://cdn.example/photo.jpg',
}),
).toEqual({ url: 'https://www.instagram.com/p/ABC/', linkOnlyReason: null })
// A carousel never offers a single file, even when a child URL leaks in.
expect(
contentPieceProfileCandidateFromPost({
permalink: 'https://www.instagram.com/p/ABC/',
mediaType: 'CAROUSEL_ALBUM',
mediaUrl: 'https://cdn.example/child.jpg',
}),
).toEqual({ url: 'https://www.instagram.com/p/ABC/', linkOnlyReason: 'carrossel' })
// Protected audio: the API omits the media URL.
expect(
contentPieceProfileCandidateFromPost({
permalink: 'https://www.instagram.com/reel/ABC/',
mediaType: 'REEL',
mediaUrl: null,
}),
).toEqual({ url: 'https://www.instagram.com/reel/ABC/', linkOnlyReason: 'indisponivel' })
})
it('refuses a permalink the catalogue cannot parse', () => {
expect(
contentPieceProfileCandidateFromPost({
permalink: 'https://www.instagram.com/stories/depjorgesolla/1/',
mediaType: 'IMAGE',
mediaUrl: 'https://cdn.example/photo.jpg',
}),
).toBeNull()
})
})
describe('contentPieceSearchText', () => {
it('normalizes title, description, transcript, topics, city and institution', () => {
expect(
contentPieceSearchText({
title: 'Fim da escala 6×1 é Saúde',
description: 'Solla explica a jornada',
transcript: 'A redução da jornada protege a saúde',
institution: 'Câmara dos Deputados',
topics: ['saude', 'economia-trabalho'],
cityLabel: 'Feira de Santana',
}),
).toBe(
'fim da escala 6×1 e saude solla explica a jornada a reducao da jornada protege a saude camara dos deputados saude economia e trabalho feira de santana',
)
})
it('is empty when nothing is filled', () => {
expect(contentPieceSearchText({})).toBe('')
})
it('includes the display names of who appears in the piece (S37)', () => {
expect(
contentPieceSearchText({
title: 'Giro',
leaderNames: ['Maria Silva'],
publicFigures: ['Dra. Elaine'],
}),
).toBe('giro maria silva dra. elaine')
})
})
describe('toContentPieceViewModel', () => {
it('maps a ready piece and its links', () => {
const viewModel = toContentPieceViewModel({
id: 7,
title: 'Fim da escala',
type: 'video',
status: 'publicado',
processingStatus: 'pronto',
step: null,
origin: 'instagram',
topics: ['saude'],
cityLabel: 'Salvador — ZE 3',
region: 'Metropolitana',
durationSeconds: 134,
pieceDate: '2026-09-01',
publishedAt: '2026-09-02',
error: null,
media: { id: 11 },
})
expect(viewModel.title).toBe('Fim da escala')
expect(viewModel.typeLabel).toBe('Vídeo')
expect(viewModel.statusLabel).toBe('Publicado')
expect(viewModel.processingLabel).toBe('Pronto')
expect(viewModel.durationLabel).toBe('02:14')
expect(viewModel.pieceDateLabel).toBe('01/09/2026')
expect(viewModel.hasFile).toBe(true)
expect(viewModel.fileHref).toBe('/campanha/comunicacao/conteudos/7/arquivo')
expect(viewModel.retryHref).toBe('/campanha/comunicacao/conteudos/7/retry')
expect(viewModel.detailHref).toBe('/campanha/comunicacao/conteudos/7')
expect(viewModel.canRetry).toBe(false)
expect(viewModel.failureMessage).toBeNull()
expect(viewModel.topics).toEqual(['saude'])
})
it('normalizes unknown enum values fail-closed and maps a failure', () => {
const viewModel = toContentPieceViewModel({
id: 8,
title: null,
type: 'inexistente',
status: null,
processingStatus: null,
step: 'inexistente',
origin: null,
error: 'boom',
media: null,
})
expect(viewModel.title).toBe('Peça 8')
expect(viewModel.type).toBe('foto')
expect(viewModel.status).toBe('rascunho')
expect(viewModel.processingStatus).toBe('falhou')
expect(viewModel.step).toBeNull()
expect(viewModel.hasFile).toBe(false)
expect(viewModel.fileHref).toBeNull()
expect(viewModel.canRetry).toBe(true)
expect(viewModel.failureMessage).toBe('Não foi possível processar a peça.')
})
it('maps the peça-link reason and ignores an unknown one', () => {
const withReason = toContentPieceViewModel({
id: 9,
processingStatus: 'pronto',
origin: 'instagram',
linkFailureReason: 'carrossel',
media: null,
})
expect(withReason.linkFailureReason).toBe('carrossel')
expect(withReason.linkFailureReasonLabel).toBe('Carrossel: sem arquivo único para baixar')
expect(withReason.failureMessage).toBeNull()
const unknown = toContentPieceViewModel({ id: 10, linkFailureReason: 'explodiu' })
expect(unknown.linkFailureReason).toBeNull()
expect(unknown.linkFailureReasonLabel).toBeNull()
})
})
describe('content piece list URL', () => {
it('parses, canonicalizes and drops exhaustive facets', () => {
const state = parseContentPieceListParams({
q: ' escala ',
type: ['video', 'foto', 'texto', 'audio', 'card'],
status: ['rascunho'],
processing: ['falhou'],
page: '2',
})
expect(state).toEqual({
page: 2,
q: 'escala',
statuses: ['rascunho'],
processing: ['falhou'],
})
const resolved = resolveContentPieceListUrl({ type: ['video'], page: '1' })
expect(resolved.state).toEqual({ page: 1, types: ['video'] })
expect(resolved.href).toBe('/campanha/comunicacao/conteudos?type=video')
expect(resolved.redirectHref).toBe('/campanha/comunicacao/conteudos?type=video')
})
it('ignores unknown tokens and keeps an empty URL canonical', () => {
expect(parseContentPieceListParams({ type: ['nope'], page: '0' })).toEqual({ page: 1 })
expect(resolveContentPieceListUrl({}).href).toBe('/campanha/comunicacao/conteudos')
})
it('builds the where from the term and the facets', () => {
expect(buildContentPieceListWhere({ page: 1 })).toEqual({})
expect(
buildContentPieceListWhere({
page: 1,
q: 'Saúde',
types: ['video'],
statuses: ['publicado'],
processing: ['falhou'],
}),
).toEqual({
and: [
{ searchText: { contains: 'saude' } },
{ type: { in: ['video'] } },
{ status: { in: ['publicado'] } },
{ processingStatus: { in: ['falhou'] } },
],
})
expect(buildContentPieceListWhere({ page: 1, q: 'x' })).toEqual({
searchText: { contains: 'x' },
})
})
})