mirror of
https://github.com/fsolla/teqo.git
synced 2026-11-12 22:13:05 +00:00
CLI operacional `content:instagram:import`: lê o feed oficial do perfil, filtra a janela por dias (default 60), deduplica pela identidade do post e processa cada novidade inline pelo pipeline C220 (download, transcrição, catalogação); --publish publica ao final o que terminou pronto, mais antigo primeiro, e revalida a Central pública. Guardas C155/C195/C231 (TEQO_ENV + nome exato do banco, confirmação explícita, S3_* obrigatórias, dry-run padrão).
2660 lines
93 KiB
TypeScript
2660 lines
93 KiB
TypeScript
// @vitest-environment node
|
|
|
|
import { spawnSync } from 'node:child_process'
|
|
import { existsSync, readFileSync, rmSync } from 'node:fs'
|
|
import { mkdir, mkdtemp, rm, writeFile } from 'node:fs/promises'
|
|
import { tmpdir } from 'node:os'
|
|
import { join } from 'node:path'
|
|
import { fileURLToPath } from 'node:url'
|
|
|
|
import { revalidateTag } from 'next/cache'
|
|
import type { Payload } from 'payload'
|
|
import { getPayload } from 'payload'
|
|
import { afterAll, beforeAll, beforeEach, describe, expect, it, vi } from 'vitest'
|
|
|
|
vi.mock('next/cache', () => ({
|
|
revalidatePath: () => undefined,
|
|
revalidateTag: vi.fn(),
|
|
unstable_cache: (fn: unknown) => fn,
|
|
}))
|
|
|
|
vi.mock('next/server', async (importOriginal) => {
|
|
const actual = await importOriginal<typeof import('next/server')>()
|
|
// The actions start the job with `after()`, which requires a request scope;
|
|
// the job itself is exercised directly below.
|
|
return { ...actual, after: () => undefined }
|
|
})
|
|
|
|
const { getCampaignUserMock } = vi.hoisted(() => ({ getCampaignUserMock: vi.fn() }))
|
|
|
|
vi.mock('@/utilities/campaignAuth', () => ({
|
|
getCampaignUser: getCampaignUserMock,
|
|
}))
|
|
|
|
import { GET } from '@/app/(campaign)/campanha/(app)/comunicacao/conteudos/[id]/arquivo/route'
|
|
import { POST as postProfileImportCreate } from '@/app/(campaign)/campanha/(app)/comunicacao/conteudos/importar/criar/route'
|
|
import { POST as postProfileImport } from '@/app/(campaign)/campanha/(app)/comunicacao/conteudos/importar/route'
|
|
import {
|
|
addContentPieceByLinkForActor,
|
|
createContentPieceFromProfilePostForActor,
|
|
deleteContentPieceForActor,
|
|
getContentPieceStatusesForActor,
|
|
listContentPieceProfileImportCandidatesForActor,
|
|
retryContentPieceForActor,
|
|
searchContentPieceLeaderOptionsForActor,
|
|
setContentPiecePublishedForActor,
|
|
updateContentPieceForActor,
|
|
} from '@/app/(campaign)/campanha/actions/contentPieces'
|
|
import { GET as getPublicPieceFrame } from '@/app/(frontend)/conteudos/[slug]/frame/route'
|
|
import { GET as getPublicPieceMedia } from '@/app/(frontend)/conteudos/[slug]/midia/route'
|
|
import type { ContentPieceCuratedField } from '@/lib/contentPiece'
|
|
import {
|
|
isCardCatalogItem,
|
|
type ContentCatalogItem,
|
|
type ContentPiecePublicItem,
|
|
} from '@/lib/contentPieceCatalog'
|
|
import {
|
|
buildContentPieceFrameFfmpegArgs,
|
|
CONTENT_PIECE_FRAME_MAX_SOURCE_BYTES,
|
|
CONTENT_PIECE_FRAME_SEEK_ATTEMPTS,
|
|
contentPieceFrameFilename,
|
|
} from '@/lib/contentPieceFrame'
|
|
import {
|
|
CONTENT_PIECE_FORBIDDEN_MESSAGE,
|
|
CONTENT_PIECE_LINK_DUPLICATE_MESSAGE,
|
|
CONTENT_PIECE_LINK_INVALID_MESSAGE,
|
|
CONTENT_PIECE_NOT_FOUND_MESSAGE,
|
|
CONTENT_PIECE_PROFILE_IMPORT_FEED_ERROR_MESSAGE,
|
|
CONTENT_PIECE_PROFILE_IMPORT_UNAVAILABLE_MESSAGE,
|
|
CONTENT_PIECE_RETRY_NOT_FAILED_MESSAGE,
|
|
} from '@/lib/schemas/contentPiece'
|
|
import { slugify } from '@/lib/slug'
|
|
import type { SpeechTopic } from '@/lib/speechFacets'
|
|
import { normalizeForSearch } from '@/lib/speechSearch'
|
|
import type { CampaignUser, ContentMedia, User } from '@/payload-types'
|
|
import config from '@/payload.config'
|
|
import {
|
|
ensureContentPieceFrame,
|
|
findContentPieceFrameMedia,
|
|
} from '@/utilities/content/contentPieceFrameJob'
|
|
import {
|
|
CONTENT_PIECE_STALE_MS,
|
|
reapStaleContentPiece,
|
|
runContentPieceJob,
|
|
} from '@/utilities/content/contentPieceJob'
|
|
import {
|
|
CONTENT_PIECE_LINK_INSTAGRAM_WINDOW,
|
|
ContentPieceMediaTooLargeError,
|
|
resolveContentPieceSource,
|
|
} from '@/utilities/content/contentPieceLink'
|
|
import { loadContentPieceListPageData } from '@/utilities/content/contentPiecePageData'
|
|
import {
|
|
CONTENT_PIECE_PROFILE_IMPORT_INSTAGRAM_WINDOW,
|
|
createContentPieceFromProfilePost,
|
|
listContentPieceProfileImportCandidates,
|
|
} from '@/utilities/content/contentPieceProfileImport'
|
|
import {
|
|
getPublishedContentPieceBySlug,
|
|
getPublishedContentPieceItems,
|
|
getPublishedContentPieceRecords,
|
|
hasPublishedContentPieces,
|
|
} from '@/utilities/content/contentPieceReads'
|
|
import { loadContentPieceCatalogSearch } from '@/utilities/content/contentPieceThemeSearch'
|
|
import {
|
|
attachContentPieceMedia,
|
|
receiveContentPieceUpload,
|
|
} from '@/utilities/content/contentPieceUpload'
|
|
import { getCollectionListingTag } from '@/utilities/documents'
|
|
import { runFfmpeg } from '@/utilities/media/ffmpeg'
|
|
import type { InstagramPost } from '@/utilities/socialFeed/instagramFeed'
|
|
|
|
import { installCampaignFixtures } from '../helpers/campaignFixtures'
|
|
|
|
const FAKE_FFMPEG = fileURLToPath(new URL('../fixtures/fake-ffmpeg.mjs', import.meta.url))
|
|
|
|
const hasFfmpeg = spawnSync('ffmpeg', ['-version']).status === 0
|
|
|
|
const VIDEO_BYTES = Buffer.from('fixture-content-piece-bytes')
|
|
|
|
/**
|
|
* C226 — the fake ffmpeg copies the source to the still, and Payload measures
|
|
* the dimensions of an image upload, so the source of a frame test has to be a
|
|
* real (1x1) JPEG. Production always extracts one; the double cannot.
|
|
*/
|
|
const FRAME_SOURCE_BYTES = Buffer.from(
|
|
'/9j/4AAQSkZJRgABAQEAYABgAAD/2wBDAAgGBgcGBQgHBwcJCQgKDBQNDAsLDBkSEw8UHRofHh0a' +
|
|
'HBwgJC4nICIsIxwcKDcpLDAxNDQ0Hyc5PTgyPC4zNDL/wAALCAABAAEBAREA/8QAFAABAAAAAAAA' +
|
|
'AAAAAAAAAAAAACf/EABQQAQAAAAAAAAAAAAAAAAAAAAD/2gAIAQEAAD8AKp//2Q==',
|
|
'base64',
|
|
)
|
|
|
|
let payload: Payload
|
|
const createdPieceIds = new Set<number>()
|
|
const createdMediaIds = new Set<number>()
|
|
|
|
const campaignFixtures = installCampaignFixtures({
|
|
getPayload: () => payload,
|
|
setPayload: (nextPayload) => {
|
|
payload = nextPayload
|
|
},
|
|
})
|
|
|
|
const withEnv = (key: string, value: string | undefined): (() => void) => {
|
|
const previous = process.env[key]
|
|
if (value === undefined) delete process.env[key]
|
|
else process.env[key] = value
|
|
return () => {
|
|
if (previous === undefined) delete process.env[key]
|
|
else process.env[key] = previous
|
|
}
|
|
}
|
|
|
|
/**
|
|
* C226 — every derived still row, by the deterministic name the job uses. The
|
|
* suite owns the whole set (the job generates on its own), so both the
|
|
* assertions and the cleanup read it from here.
|
|
*/
|
|
const frameRows = async (): Promise<ContentMedia[]> => {
|
|
const found = await payload.find({
|
|
collection: 'contentMedia',
|
|
where: { filename: { like: 'content-piece-frame-%' } },
|
|
depth: 0,
|
|
limit: 0,
|
|
pagination: false,
|
|
overrideAccess: true,
|
|
})
|
|
return found.docs
|
|
}
|
|
|
|
const createMedia = async (
|
|
name: string,
|
|
data: Buffer = VIDEO_BYTES,
|
|
mimetype = 'video/mp4',
|
|
): Promise<ContentMedia> => {
|
|
const media = await payload.create({
|
|
collection: 'contentMedia',
|
|
data: { alt: `Arquivo ${name}` },
|
|
file: { data, mimetype, name, size: data.length },
|
|
overrideAccess: true,
|
|
})
|
|
createdMediaIds.add(media.id)
|
|
return media
|
|
}
|
|
|
|
const createPiece = async ({
|
|
title = 'Fim da escala 6x1',
|
|
type = 'video',
|
|
status = 'rascunho',
|
|
processingStatus = 'pronto',
|
|
origin = 'arquivo',
|
|
withMedia = true,
|
|
mediaMimetype,
|
|
mediaData,
|
|
transcript,
|
|
topics,
|
|
institution,
|
|
sourceUrl,
|
|
curatedFields,
|
|
}: {
|
|
title?: string
|
|
type?: 'video' | 'foto' | 'texto' | 'audio' | 'card'
|
|
status?: 'rascunho' | 'publicado'
|
|
processingStatus?: 'processando' | 'pronto' | 'falhou'
|
|
origin?: 'arquivo' | 'instagram' | 'youtube'
|
|
withMedia?: boolean
|
|
/** The stored MIME decides how the card renders, so a photo piece needs a real one. */
|
|
mediaMimetype?: string
|
|
/** The uploaded bytes (a frame fixture needs a real image, see FRAME_SOURCE_BYTES). */
|
|
mediaData?: Buffer
|
|
transcript?: string
|
|
topics?: SpeechTopic[]
|
|
institution?: string
|
|
sourceUrl?: string
|
|
curatedFields?: ContentPieceCuratedField[]
|
|
} = {}) => {
|
|
const extension = mediaMimetype === 'image/png' ? 'png' : 'mp4'
|
|
const media = withMedia
|
|
? await createMedia(
|
|
`${title}.${extension}`,
|
|
mediaData ?? VIDEO_BYTES,
|
|
mediaMimetype ?? 'video/mp4',
|
|
)
|
|
: null
|
|
const piece = await payload.create({
|
|
collection: 'contentPiece',
|
|
data: {
|
|
title,
|
|
type,
|
|
status,
|
|
processingStatus,
|
|
origin,
|
|
...(media ? { media: media.id } : {}),
|
|
...(transcript ? { transcript } : {}),
|
|
...(topics ? { topics } : {}),
|
|
...(institution ? { institution } : {}),
|
|
...(sourceUrl ? { sourceUrl } : {}),
|
|
...(curatedFields ? { curatedFields } : {}),
|
|
},
|
|
overrideAccess: true,
|
|
})
|
|
createdPieceIds.add(piece.id)
|
|
return { piece, media }
|
|
}
|
|
|
|
const callFileRoute = async ({
|
|
pieceId,
|
|
user,
|
|
range,
|
|
download,
|
|
}: {
|
|
pieceId: number
|
|
user?: CampaignUser | null
|
|
range?: string
|
|
download?: boolean
|
|
}): Promise<Response> => {
|
|
getCampaignUserMock.mockResolvedValue(user ?? null)
|
|
const request = new Request(
|
|
`http://localhost/campanha/comunicacao/conteudos/${pieceId}/arquivo${download ? '?download=1' : ''}`,
|
|
{ headers: range ? { range } : undefined },
|
|
)
|
|
return GET(request, { params: Promise.resolve({ id: String(pieceId) }) })
|
|
}
|
|
|
|
const callPublicPieceMedia = async ({
|
|
slug,
|
|
range,
|
|
download,
|
|
}: {
|
|
slug: string
|
|
range?: string
|
|
download?: boolean
|
|
}): Promise<Response> =>
|
|
getPublicPieceMedia(
|
|
new Request(`http://localhost/conteudos/${slug}/midia${download ? '?download=1' : ''}`, {
|
|
headers: range ? { range } : undefined,
|
|
}),
|
|
{ params: Promise.resolve({ slug }) },
|
|
)
|
|
|
|
const transcribeOk = async () => ({
|
|
ok: true as const,
|
|
segments: [
|
|
{ start: 1, end: 4, text: 'A redução da jornada é saúde' },
|
|
{ start: 5, end: 9, text: 'em Feira de Santana' },
|
|
],
|
|
durationSeconds: 134,
|
|
})
|
|
|
|
const catalogStub = async () => ({
|
|
title: 'Fim da escala 6x1 é saúde',
|
|
description: 'Solla explica a jornada.',
|
|
topics: ['saude'] as SpeechTopic[],
|
|
source: 'ai' as const,
|
|
})
|
|
|
|
const instagramPost = (overrides: Partial<InstagramPost> = {}): InstagramPost => ({
|
|
id: 'media-1',
|
|
caption: 'Legenda oficial',
|
|
mediaType: 'REEL',
|
|
permalink: 'https://www.instagram.com/reel/ABC123/',
|
|
mediaUrl: 'https://cdn.example/reel.mp4',
|
|
timestamp: '2026-09-01T10:00:00+00:00',
|
|
...overrides,
|
|
})
|
|
|
|
/**
|
|
* C220 — the resolver reads the global for credentials; saving it triggers the
|
|
* Instagram sync hook, so the tests stub `fetch` to keep the hook off the
|
|
* network (the hook swallows the failure).
|
|
*/
|
|
const setInstagramSettings = async (configured: boolean): Promise<void> => {
|
|
const fetchSpy = vi.spyOn(globalThis, 'fetch').mockRejectedValue(new Error('offline'))
|
|
try {
|
|
await payload.updateGlobal({
|
|
slug: 'social-feed-settings',
|
|
data: {
|
|
enabled: true,
|
|
instagramEnabled: configured,
|
|
instagramAccessToken: configured ? 'test-token' : '',
|
|
instagramUserId: configured ? '17841400000000000' : '',
|
|
},
|
|
overrideAccess: true,
|
|
})
|
|
} finally {
|
|
fetchSpy.mockRestore()
|
|
}
|
|
}
|
|
|
|
const withTempDir = async <T>(run: (tempDir: string) => Promise<T>): Promise<T> => {
|
|
const tempDir = await mkdtemp(join(tmpdir(), 'content-piece-link-'))
|
|
try {
|
|
return await run(tempDir)
|
|
} finally {
|
|
await rm(tempDir, { recursive: true, force: true })
|
|
}
|
|
}
|
|
|
|
/**
|
|
* S38 — the loader returns pieces plus the synthetic card items; the piece
|
|
* assertions narrow here (the card side has no id/themeMatch by contract).
|
|
*/
|
|
const pieceRows = (rows: readonly ContentCatalogItem[]): ContentPiecePublicItem[] =>
|
|
rows.filter((row): row is ContentPiecePublicItem => !isCardCatalogItem(row))
|
|
|
|
describe('content pieces (C211)', () => {
|
|
let communicator: CampaignUser
|
|
let coordinator: CampaignUser
|
|
let candidate: CampaignUser
|
|
let advisor: CampaignUser
|
|
let leader: CampaignUser
|
|
|
|
beforeAll(async () => {
|
|
payload = await getPayload({ config: await config })
|
|
})
|
|
|
|
beforeEach(async () => {
|
|
const fixtures = campaignFixtures()
|
|
;[communicator, coordinator, candidate, advisor, leader] = await Promise.all([
|
|
fixtures.createCampaignUser('communicator'),
|
|
fixtures.createCampaignUser('coordinator'),
|
|
fixtures.createCampaignUser('candidate'),
|
|
fixtures.createCampaignUser('advisor'),
|
|
fixtures.createCampaignUser('leader'),
|
|
])
|
|
getCampaignUserMock.mockReset()
|
|
})
|
|
|
|
afterAll(async () => {
|
|
for (const id of createdPieceIds) {
|
|
await payload
|
|
.delete({ collection: 'contentPiece', id, overrideAccess: true })
|
|
.catch(() => undefined)
|
|
}
|
|
// C226 — the job generates the still on its own, so those rows are not in
|
|
// `createdMediaIds`; the derived cache never outlives the suite.
|
|
for (const frame of await frameRows()) {
|
|
await payload
|
|
.delete({ collection: 'contentMedia', id: frame.id, overrideAccess: true })
|
|
.catch(() => undefined)
|
|
}
|
|
for (const id of createdMediaIds) {
|
|
await payload
|
|
.delete({ collection: 'contentMedia', id, overrideAccess: true })
|
|
.catch(() => undefined)
|
|
}
|
|
})
|
|
|
|
it('reads pieces only with the communication roles', async () => {
|
|
const { piece } = await createPiece()
|
|
|
|
const readAs = (user?: CampaignUser) =>
|
|
payload.find({
|
|
collection: 'contentPiece',
|
|
where: { id: { equals: piece.id } },
|
|
depth: 0,
|
|
limit: 1,
|
|
...(user ? { user } : {}),
|
|
overrideAccess: false,
|
|
})
|
|
|
|
await expect(readAs()).rejects.toThrow()
|
|
await expect(readAs(advisor)).rejects.toThrow()
|
|
await expect(readAs(leader)).rejects.toThrow()
|
|
expect((await readAs(communicator)).docs).toHaveLength(1)
|
|
expect((await readAs(coordinator)).docs).toHaveLength(1)
|
|
expect((await readAs(candidate)).docs).toHaveLength(1)
|
|
})
|
|
|
|
it('serves the private file to the communication roles and 404s everyone else', async () => {
|
|
const { piece } = await createPiece()
|
|
|
|
const response = await callFileRoute({ pieceId: piece.id, user: communicator })
|
|
expect(response.status).toBe(200)
|
|
expect(Buffer.from(await response.arrayBuffer())).toEqual(VIDEO_BYTES)
|
|
|
|
const partial = await callFileRoute({
|
|
pieceId: piece.id,
|
|
user: coordinator,
|
|
range: 'bytes=0-3',
|
|
})
|
|
expect(partial.status).toBe(206)
|
|
|
|
const download = await callFileRoute({ pieceId: piece.id, user: candidate, download: true })
|
|
expect(download.headers.get('Content-Disposition')).toContain('attachment')
|
|
|
|
for (const user of [undefined, advisor, leader]) {
|
|
expect((await callFileRoute({ pieceId: piece.id, user })).status).toBe(404)
|
|
}
|
|
})
|
|
|
|
it('streams an upload to the private media and schedules the job only when needed', async () => {
|
|
const body = new ReadableStream<Uint8Array>({
|
|
start(controller) {
|
|
controller.enqueue(Buffer.from('raw-body-piece'))
|
|
controller.close()
|
|
},
|
|
})
|
|
const scheduled: number[] = []
|
|
|
|
const { id } = await receiveContentPieceUpload({
|
|
payload,
|
|
actor: communicator,
|
|
type: 'video',
|
|
metadata: { filename: 'fim da escala.mp4' },
|
|
body,
|
|
contentLength: 13,
|
|
startJob: (pieceId) => scheduled.push(pieceId),
|
|
})
|
|
createdPieceIds.add(id)
|
|
|
|
const piece = await payload.findByID({
|
|
collection: 'contentPiece',
|
|
id,
|
|
depth: 1,
|
|
overrideAccess: true,
|
|
})
|
|
expect(piece.title).toBe('fim da escala')
|
|
expect(piece.processingStatus).toBe('processando')
|
|
expect(piece.step).toBe('extraindo')
|
|
expect(piece.status).toBe('rascunho')
|
|
expect(piece.origin).toBe('arquivo')
|
|
expect(typeof piece.media === 'object' ? piece.media?.filename : null).toBeTruthy()
|
|
expect(scheduled).toEqual([id])
|
|
|
|
const photoBody = new ReadableStream<Uint8Array>({
|
|
start(controller) {
|
|
controller.enqueue(Buffer.from('image-bytes'))
|
|
controller.close()
|
|
},
|
|
})
|
|
const { id: photoId } = await receiveContentPieceUpload({
|
|
payload,
|
|
actor: communicator,
|
|
type: 'foto',
|
|
metadata: { filename: 'card-feira.png' },
|
|
body: photoBody,
|
|
contentLength: 11,
|
|
startJob: (pieceId) => scheduled.push(pieceId),
|
|
})
|
|
createdPieceIds.add(photoId)
|
|
|
|
const photo = await payload.findByID({
|
|
collection: 'contentPiece',
|
|
id: photoId,
|
|
depth: 0,
|
|
overrideAccess: true,
|
|
})
|
|
expect(photo.processingStatus).toBe('pronto')
|
|
expect(photo.step).toBeNull()
|
|
// A photo is catalogued by the assessoria: no job was scheduled for it.
|
|
expect(scheduled).toEqual([id])
|
|
})
|
|
|
|
it('extracts a text piece without scheduling a transcription', async () => {
|
|
const text = 'Mensagem para as lideranças: peça voto pra Solla 1313.'
|
|
const body = new ReadableStream<Uint8Array>({
|
|
start(controller) {
|
|
controller.enqueue(Buffer.from(text))
|
|
controller.close()
|
|
},
|
|
})
|
|
const scheduled: number[] = []
|
|
|
|
const { id } = await receiveContentPieceUpload({
|
|
payload,
|
|
actor: communicator,
|
|
type: 'texto',
|
|
metadata: { filename: 'mensagem.txt' },
|
|
body,
|
|
contentLength: Buffer.byteLength(text),
|
|
startJob: (pieceId) => scheduled.push(pieceId),
|
|
})
|
|
createdPieceIds.add(id)
|
|
|
|
const piece = await payload.findByID({
|
|
collection: 'contentPiece',
|
|
id,
|
|
depth: 0,
|
|
overrideAccess: true,
|
|
})
|
|
expect(piece.transcript).toBe(text)
|
|
expect(piece.processingStatus).toBe('processando')
|
|
expect(scheduled).toEqual([id])
|
|
})
|
|
|
|
it('preserves a long content-piece transcript and its search text', async () => {
|
|
const longText = 'texto da peça '.repeat(3_000)
|
|
const { piece } = await createPiece({ transcript: longText })
|
|
|
|
const updated = await payload.findByID({
|
|
collection: 'contentPiece',
|
|
id: piece.id,
|
|
depth: 0,
|
|
overrideAccess: true,
|
|
})
|
|
|
|
expect(updated.transcript).toBe(longText)
|
|
expect(updated.searchText).toContain(normalizeForSearch(longText))
|
|
})
|
|
|
|
it('attaches the original file to a link piece and refuses a second attachment', async () => {
|
|
getCampaignUserMock.mockResolvedValue(communicator)
|
|
const piece = await addContentPieceByLinkForActor({
|
|
url: 'https://www.youtube.com/watch?v=ATTACH1',
|
|
})
|
|
createdPieceIds.add(piece.id)
|
|
|
|
const bytes = Buffer.from('original-upload-bytes')
|
|
const body = new ReadableStream<Uint8Array>({
|
|
start(controller) {
|
|
controller.enqueue(bytes)
|
|
controller.close()
|
|
},
|
|
})
|
|
const scheduled: number[] = []
|
|
|
|
await attachContentPieceMedia({
|
|
payload,
|
|
actor: communicator,
|
|
piece: { id: piece.id, title: 'Peça por link', type: 'video' },
|
|
filename: 'original do grupo.mp4',
|
|
body,
|
|
contentLength: bytes.length,
|
|
startJob: (pieceId) => scheduled.push(pieceId),
|
|
})
|
|
|
|
const attached = await payload.findByID({
|
|
collection: 'contentPiece',
|
|
id: piece.id,
|
|
depth: 1,
|
|
overrideAccess: true,
|
|
})
|
|
expect(attached.processingStatus).toBe('processando')
|
|
expect(attached.step).toBe('extraindo')
|
|
expect(scheduled).toEqual([piece.id])
|
|
const media = typeof attached.media === 'object' ? attached.media : null
|
|
// Payload suffixes a name already on disk (`-1`), so the assertion is the
|
|
// sanitized stem plus the preserved extension, not an exact run-dependent name.
|
|
expect(media?.filename).toMatch(/^original_do_grupo(-\d+)?\.mp4$/)
|
|
expect(media?.filesize).toBe(bytes.length)
|
|
|
|
// A second attachment is refused: the archived original is not replaced.
|
|
await expect(
|
|
attachContentPieceMedia({
|
|
payload,
|
|
actor: communicator,
|
|
piece: { id: piece.id, title: 'Peça por link', type: 'video', media: media?.id ?? null },
|
|
filename: 'outro.mp4',
|
|
body: new ReadableStream<Uint8Array>({
|
|
start(controller) {
|
|
controller.enqueue(bytes)
|
|
controller.close()
|
|
},
|
|
}),
|
|
contentLength: bytes.length,
|
|
startJob: () => undefined,
|
|
}),
|
|
).rejects.toThrow()
|
|
})
|
|
|
|
it('runs the video pipeline to ready with transcript, duration, city and search text', async () => {
|
|
const { piece } = await createPiece({
|
|
processingStatus: 'processando',
|
|
title: 'Visita ao posto',
|
|
})
|
|
|
|
const restoreFfmpeg = withEnv('FFMPEG_PATH', FAKE_FFMPEG)
|
|
const restoreSegments = withEnv('FAKE_FFMPEG_SEGMENTS', '1')
|
|
try {
|
|
await runContentPieceJob(payload, piece.id, {
|
|
transcribe: transcribeOk,
|
|
catalog: catalogStub,
|
|
})
|
|
} finally {
|
|
restoreSegments()
|
|
restoreFfmpeg()
|
|
}
|
|
|
|
const updated = await payload.findByID({
|
|
collection: 'contentPiece',
|
|
id: piece.id,
|
|
depth: 0,
|
|
overrideAccess: true,
|
|
})
|
|
expect(updated.processingStatus).toBe('pronto')
|
|
expect(updated.step).toBeNull()
|
|
expect(updated.error).toBeNull()
|
|
expect(updated.durationSeconds).toBe(134)
|
|
expect(updated.transcript).toContain('A redução da jornada é saúde')
|
|
expect(updated.title).toBe('Fim da escala 6x1 é saúde')
|
|
expect(updated.description).toBe('Solla explica a jornada.')
|
|
expect(updated.topics).toEqual(['saude'])
|
|
// The derive hook owns the search text: title + description + transcript.
|
|
expect(updated.searchText).toContain('fim da escala 6x1 e saude')
|
|
expect(updated.searchText).toContain('a reducao da jornada e saude')
|
|
// The file is preserved after processing.
|
|
expect(updated.media).toBeTruthy()
|
|
})
|
|
|
|
it('marks a transcription failure as failed, preserves the file and retries', async () => {
|
|
const { piece } = await createPiece({
|
|
processingStatus: 'processando',
|
|
title: 'Áudio do grupo',
|
|
type: 'audio',
|
|
})
|
|
getCampaignUserMock.mockResolvedValue(communicator)
|
|
|
|
const restore = withEnv('FFMPEG_PATH', FAKE_FFMPEG)
|
|
try {
|
|
await runContentPieceJob(payload, piece.id, {
|
|
transcribe: async () => ({ ok: false as const, error: 'provider fora', status: 502 }),
|
|
catalog: catalogStub,
|
|
})
|
|
} finally {
|
|
restore()
|
|
}
|
|
|
|
const failed = await payload.findByID({
|
|
collection: 'contentPiece',
|
|
id: piece.id,
|
|
depth: 0,
|
|
overrideAccess: true,
|
|
})
|
|
expect(failed.processingStatus).toBe('falhou')
|
|
expect(failed.step).toBe('transcrevendo')
|
|
expect(failed.error).toBe('provider fora')
|
|
expect(failed.media).toBeTruthy()
|
|
|
|
const retried = await retryContentPieceForActor({ contentPieceId: piece.id })
|
|
expect(retried.processingStatus).toBe('processando')
|
|
|
|
// A second retry on a piece already processing is refused.
|
|
await expect(retryContentPieceForActor({ contentPieceId: piece.id })).rejects.toThrow(
|
|
CONTENT_PIECE_RETRY_NOT_FAILED_MESSAGE,
|
|
)
|
|
})
|
|
|
|
it('refuses a retry from a denied role', async () => {
|
|
const { piece } = await createPiece({ processingStatus: 'falhou' })
|
|
getCampaignUserMock.mockResolvedValue(advisor)
|
|
|
|
await expect(retryContentPieceForActor({ contentPieceId: piece.id })).rejects.toThrow(
|
|
CONTENT_PIECE_FORBIDDEN_MESSAGE,
|
|
)
|
|
})
|
|
|
|
describe('delete a piece (C222)', () => {
|
|
const mediaGone = async (mediaId: number): Promise<boolean> => {
|
|
for (let attempt = 0; attempt < 20; attempt += 1) {
|
|
const gone = await payload
|
|
.findByID({ collection: 'contentMedia', id: mediaId, overrideAccess: true })
|
|
.then(() => false)
|
|
.catch(() => true)
|
|
if (gone) return true
|
|
await new Promise((resolve) => setTimeout(resolve, 50))
|
|
}
|
|
return false
|
|
}
|
|
|
|
it('deletes a piece on the collection only with the communication roles and the admin', async () => {
|
|
const admin = await campaignFixtures().createAdminUser()
|
|
|
|
const deleteAs = async (user?: CampaignUser | User) => {
|
|
const { piece } = await createPiece({ title: `Acesso ${Date.now()}` })
|
|
const deleted = await payload
|
|
.delete({
|
|
collection: 'contentPiece',
|
|
id: piece.id,
|
|
...(user ? { user } : {}),
|
|
overrideAccess: false,
|
|
})
|
|
.then(() => true)
|
|
.catch(() => false)
|
|
if (deleted) createdPieceIds.delete(piece.id)
|
|
return deleted
|
|
}
|
|
|
|
// Fail-closed: anonymous, advisor and leader are denied.
|
|
expect(await deleteAs()).toBe(false)
|
|
expect(await deleteAs(advisor)).toBe(false)
|
|
expect(await deleteAs(leader)).toBe(false)
|
|
// The vertical that writes/publicizes removes; the admin keeps the path.
|
|
expect(await deleteAs(communicator)).toBe(true)
|
|
expect(await deleteAs(coordinator)).toBe(true)
|
|
expect(await deleteAs(candidate)).toBe(true)
|
|
expect(await deleteAs(admin)).toBe(true)
|
|
})
|
|
|
|
it('hard deletes the piece with its private media and leaves the circulation events', async () => {
|
|
const marker = Date.now().toString(36)
|
|
const { piece, media } = await createPiece({
|
|
title: `Peça para apagar ${marker}`,
|
|
status: 'publicado',
|
|
})
|
|
const event = await payload.create({
|
|
collection: 'contentEvent',
|
|
data: { type: 'abertura', subjectType: 'peca', subjectId: String(piece.id) },
|
|
overrideAccess: true,
|
|
})
|
|
const publicSlug = piece.slug!
|
|
expect(await getPublishedContentPieceBySlug(publicSlug)).not.toBeNull()
|
|
|
|
getCampaignUserMock.mockResolvedValue(communicator)
|
|
await expect(deleteContentPieceForActor({ contentPieceId: piece.id })).resolves.toEqual({
|
|
deleted: true,
|
|
})
|
|
createdPieceIds.delete(piece.id)
|
|
|
|
await expect(
|
|
payload.findByID({ collection: 'contentPiece', id: piece.id, overrideAccess: true }),
|
|
).rejects.toThrow()
|
|
expect(await getPublishedContentPieceBySlug(publicSlug)).toBeNull()
|
|
|
|
if (media) {
|
|
// The media cleanup runs after commit, best-effort; poll for it.
|
|
expect(await mediaGone(media.id)).toBe(true)
|
|
createdMediaIds.delete(media.id)
|
|
}
|
|
|
|
// C213 contract: the anonymous counters are inert history, never cascaded.
|
|
const survivor = await payload.findByID({
|
|
collection: 'contentEvent',
|
|
id: event.id,
|
|
overrideAccess: true,
|
|
})
|
|
expect(survivor.subjectId).toBe(String(piece.id))
|
|
await payload.delete({ collection: 'contentEvent', id: event.id, overrideAccess: true })
|
|
})
|
|
|
|
it('refuses the delete from a denied role, keeps the piece and 404s an unknown id', async () => {
|
|
const { piece } = await createPiece()
|
|
getCampaignUserMock.mockResolvedValue(advisor)
|
|
|
|
await expect(deleteContentPieceForActor({ contentPieceId: piece.id })).rejects.toThrow(
|
|
CONTENT_PIECE_FORBIDDEN_MESSAGE,
|
|
)
|
|
expect(
|
|
await payload.findByID({ collection: 'contentPiece', id: piece.id, overrideAccess: true }),
|
|
).toMatchObject({ id: piece.id })
|
|
|
|
getCampaignUserMock.mockResolvedValue(communicator)
|
|
await expect(
|
|
deleteContentPieceForActor({ contentPieceId: piece.id + 100_000 }),
|
|
).rejects.toThrow(CONTENT_PIECE_NOT_FOUND_MESSAGE)
|
|
})
|
|
|
|
it('deletes a piece in any state and never trips the running worker', async () => {
|
|
getCampaignUserMock.mockResolvedValue(communicator)
|
|
|
|
for (const [status, processingStatus] of [
|
|
['rascunho', 'pronto'],
|
|
['publicado', 'pronto'],
|
|
['rascunho', 'falhou'],
|
|
['rascunho', 'processando'],
|
|
] as const) {
|
|
const { piece } = await createPiece({ status, processingStatus })
|
|
await deleteContentPieceForActor({ contentPieceId: piece.id })
|
|
createdPieceIds.delete(piece.id)
|
|
await expect(
|
|
payload.findByID({ collection: 'contentPiece', id: piece.id, overrideAccess: true }),
|
|
).rejects.toThrow()
|
|
}
|
|
|
|
// A job still scheduled for a deleted `processando` row finds nothing and
|
|
// resolves: the row is gone, no failure and no media is recreated.
|
|
const { piece } = await createPiece({ processingStatus: 'processando' })
|
|
await deleteContentPieceForActor({ contentPieceId: piece.id })
|
|
createdPieceIds.delete(piece.id)
|
|
|
|
await expect(runContentPieceJob(payload, piece.id)).resolves.toBeUndefined()
|
|
const resurrected = await payload.find({
|
|
collection: 'contentPiece',
|
|
where: { id: { equals: piece.id } },
|
|
limit: 0,
|
|
pagination: false,
|
|
overrideAccess: true,
|
|
})
|
|
// The worker cannot attach media to a row that no longer exists.
|
|
expect(resurrected.docs).toHaveLength(0)
|
|
})
|
|
|
|
it('busts the public listing tag when a piece is deleted', async () => {
|
|
const tag = getCollectionListingTag('contentPiece')
|
|
|
|
const { piece } = await createPiece({ title: `Cache ${Date.now()}` })
|
|
// The create's own afterChange also busts the tag; clear it AFTER the
|
|
// create so the assertion pins the afterDelete hook alone.
|
|
vi.mocked(revalidateTag).mockClear()
|
|
await payload.delete({ collection: 'contentPiece', id: piece.id, overrideAccess: true })
|
|
createdPieceIds.delete(piece.id)
|
|
|
|
expect(revalidateTag).toHaveBeenCalledWith(tag)
|
|
})
|
|
|
|
it('carries the public path on the row for the delete warning', async () => {
|
|
getCampaignUserMock.mockResolvedValue(communicator)
|
|
const publishedPiece = await createPiece({ status: 'publicado', title: `VM ${Date.now()}` })
|
|
const draftPiece = await createPiece({
|
|
status: 'rascunho',
|
|
title: `VM rascunho ${Date.now()}`,
|
|
})
|
|
|
|
const data = await loadContentPieceListPageData(payload, communicator, {})
|
|
const publishedRow = data.rows.find((row) => row.id === publishedPiece.piece.id)
|
|
const draftRow = data.rows.find((row) => row.id === draftPiece.piece.id)
|
|
|
|
expect(publishedRow?.publicPath).toBe(`/conteudos/${publishedPiece.piece.slug}`)
|
|
expect(draftRow?.publicPath).toBeNull()
|
|
})
|
|
})
|
|
|
|
it('reaps a stale processing row to failed and reports the repaired status', async () => {
|
|
const { piece } = await createPiece({ processingStatus: 'processando' })
|
|
await payload.update({
|
|
collection: 'contentPiece',
|
|
id: piece.id,
|
|
data: { updatedAt: new Date(Date.now() - CONTENT_PIECE_STALE_MS - 60_000).toISOString() },
|
|
overrideAccess: true,
|
|
})
|
|
|
|
const reaped = await reapStaleContentPiece(payload, {
|
|
id: piece.id,
|
|
processingStatus: 'processando',
|
|
updatedAt: new Date(Date.now() - CONTENT_PIECE_STALE_MS - 60_000).toISOString(),
|
|
})
|
|
expect(reaped).toBe(true)
|
|
|
|
getCampaignUserMock.mockResolvedValue(communicator)
|
|
const statuses = await getContentPieceStatusesForActor({ contentPieceIds: [piece.id] })
|
|
expect(statuses[0]?.processingStatus).toBe('falhou')
|
|
expect(statuses[0]?.failureMessage).toContain('interrompido')
|
|
})
|
|
|
|
it('protects a curated title from the pipeline and clears the error on publish', async () => {
|
|
const { piece } = await createPiece({
|
|
processingStatus: 'processando',
|
|
title: 'Título da assessoria',
|
|
curatedFields: ['title'],
|
|
})
|
|
|
|
const restore = withEnv('FFMPEG_PATH', FAKE_FFMPEG)
|
|
try {
|
|
await runContentPieceJob(payload, piece.id, {
|
|
transcribe: transcribeOk,
|
|
catalog: catalogStub,
|
|
})
|
|
} finally {
|
|
restore()
|
|
}
|
|
|
|
const updated = await payload.findByID({
|
|
collection: 'contentPiece',
|
|
id: piece.id,
|
|
depth: 0,
|
|
overrideAccess: true,
|
|
})
|
|
expect(updated.title).toBe('Título da assessoria')
|
|
expect(updated.description).toBe('Solla explica a jornada.')
|
|
})
|
|
|
|
it('generates the slug on the first publish, suffixes collisions and preserves it', async () => {
|
|
getCampaignUserMock.mockResolvedValue(communicator)
|
|
const first = await createPiece({ title: 'Fim da escala 6x1' })
|
|
const second = await createPiece({ title: 'Fim da escala 6x1' })
|
|
|
|
const published = await setContentPiecePublishedForActor({
|
|
contentPieceId: first.piece.id,
|
|
published: true,
|
|
})
|
|
expect(published.status).toBe('publicado')
|
|
expect(published.publishedAtLabel).toBeTruthy()
|
|
|
|
const publishedSecond = await setContentPiecePublishedForActor({
|
|
contentPieceId: second.piece.id,
|
|
published: true,
|
|
})
|
|
|
|
const firstRow = await payload.findByID({
|
|
collection: 'contentPiece',
|
|
id: first.piece.id,
|
|
depth: 0,
|
|
overrideAccess: true,
|
|
})
|
|
const secondRow = await payload.findByID({
|
|
collection: 'contentPiece',
|
|
id: second.piece.id,
|
|
depth: 0,
|
|
overrideAccess: true,
|
|
})
|
|
expect(firstRow.slug).toBe('fim-da-escala-6x1')
|
|
expect(secondRow.slug).toBe('fim-da-escala-6x1-2')
|
|
expect(publishedSecond.isPublished).toBe(true)
|
|
|
|
const unpublished = await setContentPiecePublishedForActor({
|
|
contentPieceId: first.piece.id,
|
|
published: false,
|
|
})
|
|
expect(unpublished.status).toBe('rascunho')
|
|
|
|
const afterUnpublish = await payload.findByID({
|
|
collection: 'contentPiece',
|
|
id: first.piece.id,
|
|
depth: 0,
|
|
overrideAccess: true,
|
|
})
|
|
expect(afterUnpublish.slug).toBe('fim-da-escala-6x1')
|
|
expect(afterUnpublish.publishedAt).toBe(firstRow.publishedAt)
|
|
|
|
// A rename after the first publication never moves the public URL.
|
|
await payload.update({
|
|
collection: 'contentPiece',
|
|
id: first.piece.id,
|
|
data: { title: 'Outro título' },
|
|
overrideAccess: true,
|
|
})
|
|
const republished = await setContentPiecePublishedForActor({
|
|
contentPieceId: first.piece.id,
|
|
published: true,
|
|
})
|
|
expect(republished.status).toBe('publicado')
|
|
const renamed = await payload.findByID({
|
|
collection: 'contentPiece',
|
|
id: first.piece.id,
|
|
depth: 0,
|
|
overrideAccess: true,
|
|
})
|
|
expect(renamed.slug).toBe('fim-da-escala-6x1')
|
|
})
|
|
|
|
it('saves the ficha and records the curated fields', async () => {
|
|
const { piece } = await createPiece({ title: 'Peça antiga' })
|
|
getCampaignUserMock.mockResolvedValue(communicator)
|
|
|
|
const updated = await updateContentPieceForActor({
|
|
contentPieceId: piece.id,
|
|
title: 'Título curado',
|
|
description: 'Descrição da assessoria',
|
|
type: 'card',
|
|
pieceDate: '2026-09-10',
|
|
topics: ['saude', 'nao-existe'],
|
|
municipalityId: null,
|
|
institution: 'Câmara dos Deputados',
|
|
transcript: 'Texto revisado',
|
|
})
|
|
|
|
expect(updated.title).toBe('Título curado')
|
|
expect(updated.type).toBe('card')
|
|
expect(updated.topics).toEqual(['saude'])
|
|
expect(updated.pieceDateLabel).toBe('10/09/2026')
|
|
|
|
const row = await payload.findByID({
|
|
collection: 'contentPiece',
|
|
id: piece.id,
|
|
depth: 0,
|
|
overrideAccess: true,
|
|
})
|
|
expect(row.curatedFields).toEqual([
|
|
'title',
|
|
'description',
|
|
'topics',
|
|
'municipality',
|
|
'institution',
|
|
'pieceDate',
|
|
'transcript',
|
|
'type',
|
|
])
|
|
expect(row.searchText).toContain('titulo curado')
|
|
expect(row.searchText).toContain('camara dos deputados')
|
|
})
|
|
|
|
it('searches the leader options with the display-name projection only (S37)', async () => {
|
|
const fixtures = campaignFixtures()
|
|
const marker = `busca-${Date.now()}`
|
|
const contact = await fixtures.createContact({ name: `Maria Silva ${marker}` })
|
|
const municipality = await fixtures.getMunicipality()
|
|
const leadership = await fixtures.createLeadership({
|
|
contact: contact.id,
|
|
municipalities: [municipality.id],
|
|
})
|
|
|
|
getCampaignUserMock.mockResolvedValue(communicator)
|
|
const options = await searchContentPieceLeaderOptionsForActor(`Maria Silva ${marker}`)
|
|
const found = options.find((option) => option.id === leadership.id)
|
|
|
|
// The projection is exactly `{ id, label }` — no other leadership field
|
|
// (municipality, status, votes, contact) crosses the boundary.
|
|
expect(found).toEqual({ id: leadership.id, label: contact.name })
|
|
expect(Object.keys(found!).sort()).toEqual(['id', 'label'])
|
|
|
|
// Shorter than 2 chars never queries.
|
|
expect(await searchContentPieceLeaderOptionsForActor('M')).toEqual([])
|
|
|
|
// The role gate fails closed for every non-communication role.
|
|
for (const denied of [advisor, leader]) {
|
|
getCampaignUserMock.mockResolvedValue(denied)
|
|
await expect(
|
|
searchContentPieceLeaderOptionsForActor(`Maria Silva ${marker}`),
|
|
).rejects.toThrow(CONTENT_PIECE_FORBIDDEN_MESSAGE)
|
|
}
|
|
})
|
|
|
|
it('saves who appears in the piece, snapshots names and survives the pipeline (S37)', async () => {
|
|
const fixtures = campaignFixtures()
|
|
const marker = `pessoas-${Date.now()}`
|
|
const contact = await fixtures.createContact({ name: `Liderança ${marker}` })
|
|
const municipality = await fixtures.getMunicipality()
|
|
const leadership = await fixtures.createLeadership({
|
|
contact: contact.id,
|
|
municipalities: [municipality.id],
|
|
})
|
|
const { piece } = await createPiece({
|
|
processingStatus: 'processando',
|
|
title: `Peça pessoas ${marker}`,
|
|
})
|
|
getCampaignUserMock.mockResolvedValue(communicator)
|
|
|
|
await updateContentPieceForActor({
|
|
contentPieceId: piece.id,
|
|
title: `Peça pessoas ${marker}`,
|
|
description: null,
|
|
type: 'video',
|
|
pieceDate: null,
|
|
municipalityId: null,
|
|
institution: null,
|
|
transcript: null,
|
|
leaderIds: [leadership.id],
|
|
// A typed variant resolves to the catalog spelling; the free text stays.
|
|
publicFigures: ['dra elaine', 'Personalidade Sem Catálogo'],
|
|
})
|
|
|
|
const row = await payload.findByID({
|
|
collection: 'contentPiece',
|
|
id: piece.id,
|
|
depth: 0,
|
|
overrideAccess: true,
|
|
})
|
|
expect(row.leaders).toEqual([leadership.id])
|
|
expect(row.leaderNames).toEqual([contact.name])
|
|
expect(row.publicFigures).toEqual(['Dra. Elaine', 'Personalidade Sem Catálogo'])
|
|
expect(row.searchText).toContain(normalizeForSearch(contact.name))
|
|
expect(row.searchText).toContain('dra. elaine')
|
|
|
|
// The pipeline job never touches the curation fields.
|
|
const restore = withEnv('FFMPEG_PATH', FAKE_FFMPEG)
|
|
try {
|
|
await runContentPieceJob(payload, piece.id, {
|
|
transcribe: transcribeOk,
|
|
catalog: catalogStub,
|
|
})
|
|
} finally {
|
|
restore()
|
|
}
|
|
const processed = await payload.findByID({
|
|
collection: 'contentPiece',
|
|
id: piece.id,
|
|
depth: 0,
|
|
overrideAccess: true,
|
|
})
|
|
expect(processed.leaders).toEqual([leadership.id])
|
|
expect(processed.leaderNames).toEqual([contact.name])
|
|
expect(processed.publicFigures).toEqual(['Dra. Elaine', 'Personalidade Sem Catálogo'])
|
|
|
|
// Clearing the picks clears the snapshot, the figures and the haystack.
|
|
await updateContentPieceForActor({
|
|
contentPieceId: piece.id,
|
|
title: `Peça pessoas ${marker}`,
|
|
description: null,
|
|
type: 'video',
|
|
pieceDate: null,
|
|
municipalityId: null,
|
|
institution: null,
|
|
transcript: null,
|
|
leaderIds: [],
|
|
publicFigures: [],
|
|
})
|
|
const cleared = await payload.findByID({
|
|
collection: 'contentPiece',
|
|
id: piece.id,
|
|
depth: 0,
|
|
overrideAccess: true,
|
|
})
|
|
expect(cleared.leaders).toEqual([])
|
|
expect(cleared.leaderNames).toEqual([])
|
|
expect(cleared.publicFigures).toEqual([])
|
|
expect(cleared.searchText).not.toContain(normalizeForSearch(contact.name))
|
|
})
|
|
|
|
it('derives the city and the region from the related município', async () => {
|
|
const municipality = await payload.find({
|
|
collection: 'municipality',
|
|
where: { kind: { equals: 'municipio' } },
|
|
depth: 0,
|
|
limit: 1,
|
|
pagination: false,
|
|
sort: 'name',
|
|
overrideAccess: true,
|
|
})
|
|
const target = municipality.docs[0]!
|
|
const { piece } = await createPiece()
|
|
|
|
await payload.update({
|
|
collection: 'contentPiece',
|
|
id: piece.id,
|
|
data: { municipality: target.id },
|
|
overrideAccess: true,
|
|
})
|
|
|
|
const row = await payload.findByID({
|
|
collection: 'contentPiece',
|
|
id: piece.id,
|
|
depth: 0,
|
|
overrideAccess: true,
|
|
})
|
|
expect(row.cityLabel).toBe(target.name)
|
|
expect(row.region).toBe(target.region)
|
|
// The haystack is normalized (accents stripped); the city name is in it.
|
|
expect(row.searchText).toContain(normalizeForSearch(target.name))
|
|
})
|
|
|
|
it('creates a link piece without an official path as a peça-link', async () => {
|
|
getCampaignUserMock.mockResolvedValue(communicator)
|
|
|
|
const piece = await addContentPieceByLinkForActor({
|
|
url: 'https://www.youtube.com/watch?v=VIDEO1&utm_source=grupo',
|
|
})
|
|
createdPieceIds.add(piece.id)
|
|
expect(piece.origin).toBe('youtube')
|
|
expect(piece.processingStatus).toBe('processando')
|
|
|
|
const row = await payload.findByID({
|
|
collection: 'contentPiece',
|
|
id: piece.id,
|
|
depth: 0,
|
|
overrideAccess: true,
|
|
})
|
|
expect(row.sourceUrl).toBe('https://www.youtube.com/watch?v=VIDEO1')
|
|
expect(row.media).toBeNull()
|
|
|
|
// The job resolves no official path: the piece becomes a peça-link.
|
|
await runContentPieceJob(payload, piece.id, { catalog: catalogStub })
|
|
const resolved = await payload.findByID({
|
|
collection: 'contentPiece',
|
|
id: piece.id,
|
|
depth: 0,
|
|
overrideAccess: true,
|
|
})
|
|
expect(resolved.processingStatus).toBe('pronto')
|
|
expect(resolved.media).toBeNull()
|
|
// YouTube is link-only by design: no reason to explain (C220).
|
|
expect(resolved.linkFailureReason).toBeNull()
|
|
})
|
|
|
|
describe('C220 — link resolution and the honest peça-link reason', () => {
|
|
const pieceInput = (piece: {
|
|
id: number
|
|
title: string
|
|
origin: string
|
|
sourceUrl?: string | null
|
|
}) => ({
|
|
id: piece.id,
|
|
title: piece.title,
|
|
origin: piece.origin,
|
|
sourceUrl: piece.sourceUrl,
|
|
media: null,
|
|
})
|
|
|
|
it('resolves by shortcode, downloads the media and reports no reason', async () => {
|
|
await setInstagramSettings(true)
|
|
const { piece } = await createPiece({
|
|
title: 'Reel do perfil',
|
|
origin: 'instagram',
|
|
withMedia: false,
|
|
sourceUrl: 'https://www.instagram.com/depjorgesolla/reel/ABC123/',
|
|
})
|
|
let receivedMaxResults: number | null = null
|
|
|
|
const resolution = await withTempDir((tempDir) =>
|
|
resolveContentPieceSource({
|
|
payload,
|
|
piece: pieceInput(piece),
|
|
tempDir,
|
|
loadFeed: async (args) => {
|
|
receivedMaxResults = args.maxResults
|
|
return { username: 'depjorgesolla', posts: [instagramPost()] }
|
|
},
|
|
fetchImpl: async () => new Response(Buffer.from('reel-bytes'), { status: 200 }),
|
|
}),
|
|
)
|
|
|
|
// The declared window is what the resolver asks the feed for (C220 D2).
|
|
expect(receivedMaxResults).toBe(CONTENT_PIECE_LINK_INSTAGRAM_WINDOW)
|
|
expect(resolution.linkFailureReason).toBeNull()
|
|
expect(resolution.suggestedType).toBe('video')
|
|
expect(resolution.media).toBeTruthy()
|
|
if (resolution.media) createdMediaIds.add(resolution.media.id)
|
|
|
|
const row = await payload.findByID({
|
|
collection: 'contentPiece',
|
|
id: piece.id,
|
|
depth: 0,
|
|
overrideAccess: true,
|
|
})
|
|
expect(row.media).toBe(resolution.media?.id)
|
|
})
|
|
|
|
it('stops at page 1 when the pasted post is there, even if deeper cursors fail', async () => {
|
|
await setInstagramSettings(true)
|
|
const { piece } = await createPiece({
|
|
title: 'Reel recém-publicado',
|
|
origin: 'instagram',
|
|
withMedia: false,
|
|
sourceUrl: 'https://www.instagram.com/reel/RECENTE1/',
|
|
})
|
|
const calls: string[] = []
|
|
|
|
const resolution = await withTempDir((tempDir) =>
|
|
resolveContentPieceSource({
|
|
payload,
|
|
piece: pieceInput(piece),
|
|
tempDir,
|
|
// No injected `loadFeed`: the real pagination owner is under test.
|
|
fetchImpl: async (input) => {
|
|
calls.push(input)
|
|
if (input.includes('cdn.example')) {
|
|
return new Response(Buffer.from('reel-bytes'), { status: 200 })
|
|
}
|
|
if (input.includes('/media')) {
|
|
// The deeper cursor is broken on this edge (C212); the page-1
|
|
// match must never depend on asking it.
|
|
if (input.includes('after=')) {
|
|
return new Response(
|
|
JSON.stringify({ error: { message: 'cursor não suportado' } }),
|
|
{
|
|
status: 400,
|
|
},
|
|
)
|
|
}
|
|
return new Response(
|
|
JSON.stringify({
|
|
data: [
|
|
{
|
|
id: 'media-1',
|
|
caption: 'Legenda oficial',
|
|
media_type: 'REEL',
|
|
media_url: 'https://cdn.example/reel.mp4',
|
|
permalink: 'https://www.instagram.com/reel/RECENTE1/',
|
|
timestamp: '2026-09-01T10:00:00+00:00',
|
|
},
|
|
],
|
|
paging: { cursors: { after: 'CURSOR-1' } },
|
|
}),
|
|
{ status: 200 },
|
|
)
|
|
}
|
|
if (input.includes('/refresh_access_token')) {
|
|
return new Response(JSON.stringify({ access_token: 'refreshed-token' }), {
|
|
status: 200,
|
|
})
|
|
}
|
|
return new Response(JSON.stringify({ username: 'depjorgesolla' }), { status: 200 })
|
|
},
|
|
}),
|
|
)
|
|
|
|
expect(resolution.linkFailureReason).toBeNull()
|
|
expect(resolution.media).toBeTruthy()
|
|
if (resolution.media) createdMediaIds.add(resolution.media.id)
|
|
// The typical paste costs one media call; the broken deeper cursor is
|
|
// never requested (a page-1 match cannot become `indisponivel`).
|
|
expect(calls.filter((call) => call.includes('/media'))).toHaveLength(1)
|
|
expect(calls.some((call) => call.includes('after='))).toBe(false)
|
|
})
|
|
|
|
it('retries a stranded media download and still extracts the piece', async () => {
|
|
await setInstagramSettings(true)
|
|
const { piece } = await createPiece({
|
|
title: 'Reel com rede instável',
|
|
origin: 'instagram',
|
|
withMedia: false,
|
|
sourceUrl: 'https://www.instagram.com/reel/INSTAVEL1/',
|
|
})
|
|
let downloadAttempts = 0
|
|
|
|
const resolution = await withTempDir((tempDir) =>
|
|
resolveContentPieceSource({
|
|
payload,
|
|
piece: pieceInput(piece),
|
|
tempDir,
|
|
loadFeed: async () => ({
|
|
username: 'depjorgesolla',
|
|
posts: [instagramPost({ permalink: 'https://www.instagram.com/reel/INSTAVEL1/' })],
|
|
}),
|
|
fetchImpl: async (input) => {
|
|
if (input.includes('cdn.example')) {
|
|
downloadAttempts += 1
|
|
// The stranded-connect shape: undici throws before any response.
|
|
if (downloadAttempts <= 3) throw new TypeError('fetch failed')
|
|
return new Response(Buffer.from('reel-bytes'), { status: 200 })
|
|
}
|
|
return new Response(null, { status: 200 })
|
|
},
|
|
}),
|
|
)
|
|
|
|
// The lost connects are retried; the fourth attempt extracts the piece.
|
|
expect(downloadAttempts).toBe(4)
|
|
expect(resolution.linkFailureReason).toBeNull()
|
|
expect(resolution.media).toBeTruthy()
|
|
if (resolution.media) createdMediaIds.add(resolution.media.id)
|
|
})
|
|
|
|
it('reports nao-encontrado when the post is not in the window', async () => {
|
|
await setInstagramSettings(true)
|
|
const { piece } = await createPiece({
|
|
origin: 'instagram',
|
|
withMedia: false,
|
|
sourceUrl: 'https://www.instagram.com/reel/FORA1/',
|
|
})
|
|
|
|
const resolution = await withTempDir((tempDir) =>
|
|
resolveContentPieceSource({
|
|
payload,
|
|
piece: pieceInput(piece),
|
|
tempDir,
|
|
loadFeed: async () => ({ username: 'depjorgesolla', posts: [instagramPost()] }),
|
|
fetchImpl: async () => new Response(null, { status: 200 }),
|
|
}),
|
|
)
|
|
|
|
expect(resolution.media).toBeNull()
|
|
expect(resolution.linkFailureReason).toBe('nao-encontrado')
|
|
})
|
|
|
|
it('reports carrossel for a carousel and indisponivel for a post without a file', async () => {
|
|
await setInstagramSettings(true)
|
|
const { piece } = await createPiece({
|
|
origin: 'instagram',
|
|
withMedia: false,
|
|
sourceUrl: 'https://www.instagram.com/reel/CARROSSEL1/',
|
|
})
|
|
|
|
const carousel = await withTempDir((tempDir) =>
|
|
resolveContentPieceSource({
|
|
payload,
|
|
piece: pieceInput(piece),
|
|
tempDir,
|
|
loadFeed: async () => ({
|
|
username: 'depjorgesolla',
|
|
posts: [
|
|
instagramPost({
|
|
permalink: 'https://www.instagram.com/reel/CARROSSEL1/',
|
|
mediaType: 'CAROUSEL_ALBUM',
|
|
mediaUrl: null,
|
|
}),
|
|
],
|
|
}),
|
|
fetchImpl: async () => new Response(null, { status: 200 }),
|
|
}),
|
|
)
|
|
expect(carousel.linkFailureReason).toBe('carrossel')
|
|
|
|
const noFile = await withTempDir((tempDir) =>
|
|
resolveContentPieceSource({
|
|
payload,
|
|
piece: pieceInput(piece),
|
|
tempDir,
|
|
loadFeed: async () => ({
|
|
username: 'depjorgesolla',
|
|
posts: [
|
|
instagramPost({
|
|
permalink: 'https://www.instagram.com/reel/CARROSSEL1/',
|
|
mediaType: 'IMAGE',
|
|
mediaUrl: null,
|
|
}),
|
|
],
|
|
}),
|
|
fetchImpl: async () => new Response(null, { status: 200 }),
|
|
}),
|
|
)
|
|
expect(noFile.linkFailureReason).toBe('indisponivel')
|
|
})
|
|
|
|
it('reports indisponivel when the feed or the download fails', async () => {
|
|
await setInstagramSettings(true)
|
|
const { piece } = await createPiece({
|
|
origin: 'instagram',
|
|
withMedia: false,
|
|
sourceUrl: 'https://www.instagram.com/reel/INDISPONIVEL1/',
|
|
})
|
|
|
|
const feedDown = await withTempDir((tempDir) =>
|
|
resolveContentPieceSource({
|
|
payload,
|
|
piece: pieceInput(piece),
|
|
tempDir,
|
|
loadFeed: async () => {
|
|
throw new Error('API down')
|
|
},
|
|
fetchImpl: async () => new Response(null, { status: 200 }),
|
|
}),
|
|
)
|
|
expect(feedDown.linkFailureReason).toBe('indisponivel')
|
|
|
|
const downloadDown = await withTempDir((tempDir) =>
|
|
resolveContentPieceSource({
|
|
payload,
|
|
piece: pieceInput(piece),
|
|
tempDir,
|
|
loadFeed: async () => ({
|
|
username: 'depjorgesolla',
|
|
posts: [instagramPost({ permalink: 'https://www.instagram.com/reel/INDISPONIVEL1/' })],
|
|
}),
|
|
fetchImpl: async () => new Response('nope', { status: 500 }),
|
|
}),
|
|
)
|
|
expect(downloadDown.linkFailureReason).toBe('indisponivel')
|
|
})
|
|
|
|
it('reports sem-credencial without the configured credential', async () => {
|
|
await setInstagramSettings(false)
|
|
const { piece } = await createPiece({
|
|
origin: 'instagram',
|
|
withMedia: false,
|
|
sourceUrl: 'https://www.instagram.com/reel/SEMCREDENCIAL1/',
|
|
})
|
|
|
|
const resolution = await withTempDir((tempDir) =>
|
|
resolveContentPieceSource({
|
|
payload,
|
|
piece: pieceInput(piece),
|
|
tempDir,
|
|
loadFeed: async () => {
|
|
throw new Error('must not be called')
|
|
},
|
|
fetchImpl: async () => new Response(null, { status: 200 }),
|
|
}),
|
|
)
|
|
|
|
expect(resolution.linkFailureReason).toBe('sem-credencial')
|
|
})
|
|
|
|
it('keeps our 4 GB ceiling as a failure and maps a broken stream to indisponivel', async () => {
|
|
await setInstagramSettings(true)
|
|
const { piece } = await createPiece({
|
|
origin: 'instagram',
|
|
withMedia: false,
|
|
sourceUrl: 'https://www.instagram.com/reel/TETO1/',
|
|
})
|
|
const matchingFeed = async () => ({
|
|
username: 'depjorgesolla',
|
|
posts: [instagramPost({ permalink: 'https://www.instagram.com/reel/TETO1/' })],
|
|
})
|
|
|
|
const streamError = (error: Error) =>
|
|
new Response(
|
|
new ReadableStream({
|
|
start(controller) {
|
|
controller.error(error)
|
|
},
|
|
}),
|
|
{ status: 200 },
|
|
)
|
|
|
|
await expect(
|
|
withTempDir((tempDir) =>
|
|
resolveContentPieceSource({
|
|
payload,
|
|
piece: pieceInput(piece),
|
|
tempDir,
|
|
loadFeed: matchingFeed,
|
|
fetchImpl: async () => streamError(new ContentPieceMediaTooLargeError()),
|
|
}),
|
|
),
|
|
).rejects.toBeInstanceOf(ContentPieceMediaTooLargeError)
|
|
|
|
const broken = await withTempDir((tempDir) =>
|
|
resolveContentPieceSource({
|
|
payload,
|
|
piece: pieceInput(piece),
|
|
tempDir,
|
|
loadFeed: matchingFeed,
|
|
fetchImpl: async () => streamError(new Error('socket reset')),
|
|
}),
|
|
)
|
|
expect(broken.linkFailureReason).toBe('indisponivel')
|
|
})
|
|
|
|
it('persists the reason from the job and clears it on attach and retry', async () => {
|
|
const { piece } = await createPiece({
|
|
title: 'Peça com motivo',
|
|
type: 'foto',
|
|
processingStatus: 'processando',
|
|
origin: 'instagram',
|
|
withMedia: false,
|
|
sourceUrl: 'https://www.instagram.com/reel/MOTIVO1/',
|
|
})
|
|
|
|
await runContentPieceJob(payload, piece.id, {
|
|
catalog: catalogStub,
|
|
resolveSource: async () => ({
|
|
media: null,
|
|
localPath: null,
|
|
caption: null,
|
|
linkFailureReason: 'carrossel',
|
|
}),
|
|
})
|
|
|
|
const row = await payload.findByID({
|
|
collection: 'contentPiece',
|
|
id: piece.id,
|
|
depth: 0,
|
|
overrideAccess: true,
|
|
})
|
|
expect(row.processingStatus).toBe('pronto')
|
|
expect(row.linkFailureReason).toBe('carrossel')
|
|
expect(row.error).toBeNull()
|
|
|
|
// Attaching the original clears the reason (the file is the resolution).
|
|
getCampaignUserMock.mockResolvedValue(communicator)
|
|
const bytes = Buffer.from('original-bytes')
|
|
await attachContentPieceMedia({
|
|
payload,
|
|
actor: communicator,
|
|
piece: { id: piece.id, title: piece.title, type: 'foto', media: null },
|
|
filename: 'original.jpg',
|
|
body: new ReadableStream<Uint8Array>({
|
|
start(controller) {
|
|
controller.enqueue(bytes)
|
|
controller.close()
|
|
},
|
|
}),
|
|
contentLength: bytes.length,
|
|
startJob: () => undefined,
|
|
})
|
|
|
|
const attached = await payload.findByID({
|
|
collection: 'contentPiece',
|
|
id: piece.id,
|
|
depth: 0,
|
|
overrideAccess: true,
|
|
})
|
|
expect(attached.linkFailureReason).toBeNull()
|
|
expect(attached.media).toBeTruthy()
|
|
if (typeof attached.media === 'number') createdMediaIds.add(attached.media)
|
|
|
|
// A retry on a failed piece also clears a stale reason.
|
|
await payload.update({
|
|
collection: 'contentPiece',
|
|
id: piece.id,
|
|
data: {
|
|
processingStatus: 'falhou',
|
|
step: 'extraindo',
|
|
error: 'boom',
|
|
linkFailureReason: 'indisponivel',
|
|
},
|
|
overrideAccess: true,
|
|
})
|
|
const retried = await retryContentPieceForActor({ contentPieceId: piece.id })
|
|
expect(retried.processingStatus).toBe('processando')
|
|
expect(retried.linkFailureReason).toBeNull()
|
|
})
|
|
|
|
it('keeps the reason null when the resolution brings the media', async () => {
|
|
const { piece } = await createPiece({
|
|
type: 'foto',
|
|
processingStatus: 'processando',
|
|
origin: 'instagram',
|
|
withMedia: false,
|
|
sourceUrl: 'https://www.instagram.com/reel/MEDIA1/',
|
|
})
|
|
const media = await createMedia('resolvida.mp4')
|
|
|
|
await runContentPieceJob(payload, piece.id, {
|
|
catalog: catalogStub,
|
|
resolveSource: async () => ({
|
|
media,
|
|
localPath: null,
|
|
caption: 'Legenda',
|
|
linkFailureReason: null,
|
|
}),
|
|
})
|
|
|
|
const row = await payload.findByID({
|
|
collection: 'contentPiece',
|
|
id: piece.id,
|
|
depth: 0,
|
|
overrideAccess: true,
|
|
})
|
|
expect(row.processingStatus).toBe('pronto')
|
|
expect(row.linkFailureReason).toBeNull()
|
|
})
|
|
})
|
|
|
|
it('refuses a duplicate link and an invalid one', async () => {
|
|
getCampaignUserMock.mockResolvedValue(communicator)
|
|
const url = 'https://www.instagram.com/reel/DUPLICADO/'
|
|
|
|
const first = await addContentPieceByLinkForActor({ url })
|
|
createdPieceIds.add(first.id)
|
|
|
|
await expect(addContentPieceByLinkForActor({ url: `${url}?igsh=abc` })).rejects.toThrow(
|
|
CONTENT_PIECE_LINK_DUPLICATE_MESSAGE,
|
|
)
|
|
// C230 — the identity is the post, not the URL spelling: the same
|
|
// shortcode under another kind is the same duplicate.
|
|
await expect(
|
|
addContentPieceByLinkForActor({ url: 'https://www.instagram.com/p/DUPLICADO/' }),
|
|
).rejects.toThrow(CONTENT_PIECE_LINK_DUPLICATE_MESSAGE)
|
|
await expect(
|
|
addContentPieceByLinkForActor({ url: 'https://twitter.com/x/status/1' }),
|
|
).rejects.toThrow()
|
|
})
|
|
|
|
describe('C230 — import from the official profile', () => {
|
|
it('lists only the novelties and dedupes by post identity across URL kinds', async () => {
|
|
await setInstagramSettings(true)
|
|
const marker = Date.now().toString(36)
|
|
await createPiece({
|
|
title: 'Peça já catalogada',
|
|
origin: 'instagram',
|
|
withMedia: false,
|
|
sourceUrl: `https://www.instagram.com/p/EX${marker}/`,
|
|
})
|
|
|
|
let receivedMaxResults: number | null = null
|
|
const listing = await listContentPieceProfileImportCandidates({
|
|
payload,
|
|
actor: communicator,
|
|
loadFeed: async (args) => {
|
|
receivedMaxResults = args.maxResults
|
|
return {
|
|
username: 'depjorgesolla',
|
|
posts: [
|
|
instagramPost({ permalink: `https://www.instagram.com/reel/NO${marker}/` }),
|
|
instagramPost({
|
|
id: 'carrossel',
|
|
mediaType: 'CAROUSEL_ALBUM',
|
|
mediaUrl: null,
|
|
permalink: `https://www.instagram.com/p/CA${marker}/`,
|
|
}),
|
|
instagramPost({
|
|
id: 'protegido',
|
|
mediaType: 'REEL',
|
|
mediaUrl: null,
|
|
permalink: `https://www.instagram.com/reel/PR${marker}/`,
|
|
}),
|
|
// The same post already in the Central, served under another kind.
|
|
instagramPost({
|
|
id: 'existente',
|
|
permalink: `https://www.instagram.com/reel/EX${marker}/`,
|
|
}),
|
|
],
|
|
}
|
|
},
|
|
})
|
|
|
|
expect(receivedMaxResults).toBe(CONTENT_PIECE_PROFILE_IMPORT_INSTAGRAM_WINDOW)
|
|
expect(listing.found).toBe(4)
|
|
expect(listing.existingCount).toBe(1)
|
|
expect(listing.candidates).toEqual([
|
|
{ url: `https://www.instagram.com/reel/NO${marker}/`, linkOnlyReason: null },
|
|
{ url: `https://www.instagram.com/p/CA${marker}/`, linkOnlyReason: 'carrossel' },
|
|
{ url: `https://www.instagram.com/reel/PR${marker}/`, linkOnlyReason: 'indisponivel' },
|
|
])
|
|
})
|
|
|
|
it('fails closed without the credential and never touches the feed', async () => {
|
|
await setInstagramSettings(false)
|
|
let called = false
|
|
|
|
await expect(
|
|
listContentPieceProfileImportCandidates({
|
|
payload,
|
|
actor: communicator,
|
|
loadFeed: async () => {
|
|
called = true
|
|
return { username: null, posts: [] }
|
|
},
|
|
}),
|
|
).rejects.toThrow(CONTENT_PIECE_PROFILE_IMPORT_UNAVAILABLE_MESSAGE)
|
|
expect(called).toBe(false)
|
|
})
|
|
|
|
it('maps a feed failure to the honest message', async () => {
|
|
await setInstagramSettings(true)
|
|
|
|
await expect(
|
|
listContentPieceProfileImportCandidates({
|
|
payload,
|
|
actor: communicator,
|
|
loadFeed: async () => {
|
|
throw new Error('Graph API fora do ar')
|
|
},
|
|
}),
|
|
).rejects.toThrow(CONTENT_PIECE_PROFILE_IMPORT_FEED_ERROR_MESSAGE)
|
|
})
|
|
|
|
it('creates one draft in the link shape and never duplicates on re-import', async () => {
|
|
await setInstagramSettings(true)
|
|
const marker = Date.now().toString(36)
|
|
const started: number[] = []
|
|
|
|
const result = await createContentPieceFromProfilePost({
|
|
payload,
|
|
actor: communicator,
|
|
url: `https://www.instagram.com/reel/CR${marker}/`,
|
|
startJob: (id) => started.push(id),
|
|
})
|
|
expect(result.outcome).toBe('created')
|
|
expect(result.contentPieceId).not.toBeNull()
|
|
|
|
const rows = await payload.find({
|
|
collection: 'contentPiece',
|
|
where: {
|
|
sourceUrl: {
|
|
in: [
|
|
`https://www.instagram.com/p/CR${marker}/`,
|
|
`https://www.instagram.com/reel/CR${marker}/`,
|
|
`https://www.instagram.com/tv/CR${marker}/`,
|
|
],
|
|
},
|
|
},
|
|
depth: 0,
|
|
overrideAccess: true,
|
|
})
|
|
expect(rows.totalDocs).toBe(1)
|
|
createdPieceIds.add(rows.docs[0]!.id)
|
|
expect(rows.docs[0]).toMatchObject({
|
|
title: `Instagram · CR${marker}`,
|
|
type: 'video',
|
|
origin: 'instagram',
|
|
status: 'rascunho',
|
|
processingStatus: 'processando',
|
|
step: 'extraindo',
|
|
})
|
|
expect(started).toEqual([rows.docs[0]!.id])
|
|
|
|
// The same post under another kind is the same identity: no twin, no job.
|
|
await expect(
|
|
createContentPieceFromProfilePost({
|
|
payload,
|
|
actor: communicator,
|
|
url: `https://www.instagram.com/p/CR${marker}/`,
|
|
startJob: (id) => started.push(id),
|
|
}),
|
|
).resolves.toEqual({ outcome: 'existing', contentPieceId: null })
|
|
expect(started).toHaveLength(1)
|
|
})
|
|
|
|
// The ops import CLI creates without an actor (system mode) and needs the
|
|
// created id to run the job inline: same shape, admin bypass, no stamp.
|
|
it('creates in system mode without an actor and returns the created id', async () => {
|
|
await setInstagramSettings(true)
|
|
const marker = Date.now().toString(36)
|
|
const started: number[] = []
|
|
|
|
const result = await createContentPieceFromProfilePost({
|
|
payload,
|
|
actor: null,
|
|
url: `https://www.instagram.com/reel/SYS${marker}/`,
|
|
startJob: (id) => started.push(id),
|
|
})
|
|
expect(result.outcome).toBe('created')
|
|
expect(result.contentPieceId).not.toBeNull()
|
|
|
|
const row = await payload.findByID({
|
|
collection: 'contentPiece',
|
|
id: result.contentPieceId!,
|
|
depth: 0,
|
|
overrideAccess: true,
|
|
})
|
|
createdPieceIds.add(row.id)
|
|
expect(row).toMatchObject({
|
|
title: `Instagram · SYS${marker}`,
|
|
status: 'rascunho',
|
|
processingStatus: 'processando',
|
|
})
|
|
expect(row.createdBy ?? null).toBeNull()
|
|
expect(started).toEqual([row.id])
|
|
|
|
// The system probe shares the identity predicate: no twin on re-run.
|
|
await expect(
|
|
createContentPieceFromProfilePost({
|
|
payload,
|
|
actor: null,
|
|
url: `https://www.instagram.com/p/SYS${marker}/`,
|
|
}),
|
|
).resolves.toEqual({ outcome: 'existing', contentPieceId: null })
|
|
})
|
|
|
|
it('refuses a non-Instagram URL in the profile creator', async () => {
|
|
await expect(
|
|
createContentPieceFromProfilePost({
|
|
payload,
|
|
actor: communicator,
|
|
url: 'https://youtu.be/VIDEO1',
|
|
}),
|
|
).rejects.toThrow(CONTENT_PIECE_LINK_INVALID_MESSAGE)
|
|
})
|
|
|
|
it('gates the actions behind the communication catalog', async () => {
|
|
await setInstagramSettings(true)
|
|
getCampaignUserMock.mockResolvedValue(leader)
|
|
|
|
await expect(listContentPieceProfileImportCandidatesForActor()).rejects.toThrow(
|
|
CONTENT_PIECE_FORBIDDEN_MESSAGE,
|
|
)
|
|
await expect(
|
|
createContentPieceFromProfilePostForActor({ url: 'https://www.instagram.com/reel/GATE1/' }),
|
|
).rejects.toThrow(CONTENT_PIECE_FORBIDDEN_MESSAGE)
|
|
})
|
|
|
|
it('answers the JSON envelope of the two import routes', async () => {
|
|
getCampaignUserMock.mockResolvedValue(communicator)
|
|
|
|
// Without the credential the listing route refuses with the safe message.
|
|
await setInstagramSettings(false)
|
|
const unconfigured = await postProfileImport(
|
|
new Request('http://localhost/campanha/comunicacao/conteudos/importar', {
|
|
method: 'POST',
|
|
headers: { 'content-type': 'application/json', origin: 'http://localhost' },
|
|
body: JSON.stringify({}),
|
|
}),
|
|
)
|
|
expect(unconfigured.status).toBe(400)
|
|
await expect(unconfigured.json()).resolves.toEqual({
|
|
status: 'error',
|
|
message: CONTENT_PIECE_PROFILE_IMPORT_UNAVAILABLE_MESSAGE,
|
|
})
|
|
|
|
await setInstagramSettings(true)
|
|
const marker = Date.now().toString(36)
|
|
const url = `https://www.instagram.com/reel/ROTA${marker}/`
|
|
const callCreate = () =>
|
|
postProfileImportCreate(
|
|
new Request('http://localhost/campanha/comunicacao/conteudos/importar/criar', {
|
|
method: 'POST',
|
|
headers: { 'content-type': 'application/json', origin: 'http://localhost' },
|
|
body: JSON.stringify({ url }),
|
|
}),
|
|
)
|
|
|
|
const created = await callCreate()
|
|
expect(created.status).toBe(200)
|
|
await expect(created.json()).resolves.toEqual({ status: 'success', outcome: 'created' })
|
|
const rows = await payload.find({
|
|
collection: 'contentPiece',
|
|
where: { sourceUrl: { equals: url } },
|
|
depth: 0,
|
|
overrideAccess: true,
|
|
})
|
|
createdPieceIds.add(rows.docs[0]!.id)
|
|
await expect((await callCreate()).json()).resolves.toEqual({
|
|
status: 'success',
|
|
outcome: 'existing',
|
|
})
|
|
|
|
const crossOrigin = await postProfileImportCreate(
|
|
new Request('http://localhost/campanha/comunicacao/conteudos/importar/criar', {
|
|
method: 'POST',
|
|
headers: { 'content-type': 'application/json', origin: 'https://evil.example' },
|
|
body: JSON.stringify({ url }),
|
|
}),
|
|
)
|
|
expect(crossOrigin.status).toBe(403)
|
|
})
|
|
|
|
it('creates through the action without leaving the communication gate', async () => {
|
|
await setInstagramSettings(true)
|
|
const marker = Date.now().toString(36)
|
|
const url = `https://www.instagram.com/reel/ACT${marker}/`
|
|
getCampaignUserMock.mockResolvedValue(communicator)
|
|
|
|
await expect(createContentPieceFromProfilePostForActor({ url })).resolves.toEqual({
|
|
outcome: 'created',
|
|
})
|
|
|
|
const rows = await payload.find({
|
|
collection: 'contentPiece',
|
|
where: { sourceUrl: { equals: url } },
|
|
depth: 0,
|
|
overrideAccess: true,
|
|
})
|
|
expect(rows.totalDocs).toBe(1)
|
|
createdPieceIds.add(rows.docs[0]!.id)
|
|
await expect(createContentPieceFromProfilePostForActor({ url })).resolves.toEqual({
|
|
outcome: 'existing',
|
|
})
|
|
})
|
|
})
|
|
|
|
it('filters and searches the list', async () => {
|
|
const municipality = await payload.find({
|
|
collection: 'municipality',
|
|
where: { kind: { equals: 'municipio' } },
|
|
depth: 0,
|
|
limit: 1,
|
|
pagination: false,
|
|
sort: 'name',
|
|
overrideAccess: true,
|
|
})
|
|
const target = municipality.docs[0]!
|
|
|
|
// The suite shares one database and the rows of every test stay until the
|
|
// end: each assertion is scoped by a marker only this test creates.
|
|
const matchMarker = `escala-${Date.now()}`
|
|
const otherMarker = `card-${Date.now()}`
|
|
const match = await createPiece({
|
|
title: `Fim da escala 6x1 ${matchMarker}`,
|
|
topics: ['saude'],
|
|
institution: 'Câmara dos Deputados',
|
|
})
|
|
await payload.update({
|
|
collection: 'contentPiece',
|
|
id: match.piece.id,
|
|
data: { municipality: target.id },
|
|
overrideAccess: true,
|
|
})
|
|
const other = await createPiece({
|
|
title: `Card do giro ${otherMarker}`,
|
|
type: 'card',
|
|
status: 'publicado',
|
|
})
|
|
|
|
const idsOf = async (params: Record<string, string | string[]>) =>
|
|
(await loadContentPieceListPageData(payload, communicator, params)).rows.map((row) => row.id)
|
|
|
|
expect(await idsOf({ q: matchMarker })).toEqual([match.piece.id])
|
|
expect(await idsOf({ q: otherMarker })).toEqual([other.piece.id])
|
|
expect(await idsOf({ q: target.name.split(' ')[0]! })).toContain(match.piece.id)
|
|
expect(await idsOf({ q: 'camara dos deputados' })).toContain(match.piece.id)
|
|
expect(await idsOf({ type: ['card'] })).toContain(other.piece.id)
|
|
expect(await idsOf({ status: ['publicado'] })).toContain(other.piece.id)
|
|
expect(await idsOf({ processing: ['pronto'] })).toEqual(
|
|
expect.arrayContaining([match.piece.id, other.piece.id]),
|
|
)
|
|
|
|
// The loader answers for the communication roles only.
|
|
await expect(loadContentPieceListPageData(payload, leader, {})).rejects.toThrow()
|
|
})
|
|
|
|
it('shows only published pieces with file or link in the public read', async () => {
|
|
const marker = `pub-${Date.now()}`
|
|
const draft = await createPiece({ title: `Rascunho ${marker}` })
|
|
const published = await createPiece({ title: `Publicada ${marker}` })
|
|
const empty = await createPiece({ title: `Vazia ${marker}`, withMedia: false })
|
|
const link = await createPiece({
|
|
title: `Link ${marker}`,
|
|
withMedia: false,
|
|
origin: 'youtube',
|
|
sourceUrl: `https://www.youtube.com/watch?v=PUB${Date.now()}`,
|
|
})
|
|
|
|
await payload.update({
|
|
collection: 'contentPiece',
|
|
id: published.piece.id,
|
|
data: { status: 'publicado' },
|
|
overrideAccess: true,
|
|
})
|
|
await payload.update({
|
|
collection: 'contentPiece',
|
|
id: empty.piece.id,
|
|
data: { status: 'publicado' },
|
|
overrideAccess: true,
|
|
})
|
|
await payload.update({
|
|
collection: 'contentPiece',
|
|
id: link.piece.id,
|
|
data: { status: 'publicado' },
|
|
overrideAccess: true,
|
|
})
|
|
|
|
const items = await getPublishedContentPieceItems()
|
|
const ids = items.map((item) => item.id)
|
|
const publishedItem = items.find((item) => item.id === published.piece.id)
|
|
|
|
expect(ids).toContain(published.piece.id)
|
|
expect(ids).toContain(link.piece.id)
|
|
expect(ids).not.toContain(draft.piece.id)
|
|
// Published but with nothing to show (no file, no link) fails closed.
|
|
expect(ids).not.toContain(empty.piece.id)
|
|
expect(publishedItem?.slug).toMatch(new RegExp(`^publicada-${marker}`))
|
|
expect(await getPublishedContentPieceBySlug(publishedItem!.slug)).toMatchObject({
|
|
id: published.piece.id,
|
|
file: { path: `/conteudos/${publishedItem!.slug}/midia` },
|
|
})
|
|
expect(await getPublishedContentPieceBySlug(`nao-existe-${marker}`)).toBeNull()
|
|
expect(await hasPublishedContentPieces()).toBe(true)
|
|
})
|
|
|
|
it('derives the people facet and the name search from published pieces only (S37)', async () => {
|
|
const fixtures = campaignFixtures()
|
|
const marker = `facet-${Date.now()}`
|
|
const contactName = `Liderança Pública ${marker}`
|
|
const contact = await fixtures.createContact({ name: contactName })
|
|
const municipality = await fixtures.getMunicipality()
|
|
const leadership = await fixtures.createLeadership({
|
|
contact: contact.id,
|
|
municipalities: [municipality.id],
|
|
})
|
|
|
|
const published = await createPiece({ title: `Publicada pessoas ${marker}` })
|
|
const draft = await createPiece({ title: `Rascunho pessoas ${marker}` })
|
|
getCampaignUserMock.mockResolvedValue(communicator)
|
|
|
|
for (const [id, title] of [
|
|
[published.piece.id, `Publicada pessoas ${marker}`],
|
|
[draft.piece.id, `Rascunho pessoas ${marker}`],
|
|
] as const) {
|
|
await updateContentPieceForActor({
|
|
contentPieceId: id,
|
|
title,
|
|
description: null,
|
|
type: 'video',
|
|
pieceDate: null,
|
|
municipalityId: null,
|
|
institution: null,
|
|
transcript: null,
|
|
leaderIds: [leadership.id],
|
|
publicFigures: ['Dra. Elaine'],
|
|
})
|
|
}
|
|
await payload.update({
|
|
collection: 'contentPiece',
|
|
id: published.piece.id,
|
|
data: { status: 'publicado' },
|
|
overrideAccess: true,
|
|
})
|
|
|
|
const personSlug = slugify(contactName)
|
|
const records = await getPublishedContentPieceRecords()
|
|
const record = records.find((candidate) => candidate.id === published.piece.id)
|
|
expect(record?.leaderNames).toEqual([contactName])
|
|
expect(record?.publicFigures).toEqual(['Dra. Elaine'])
|
|
// The public select never carries the relation (depth 1 would leak Contact).
|
|
expect(record && 'leaders' in record).toBe(false)
|
|
|
|
const filtered = await loadContentPieceCatalogSearch({
|
|
rawSearchParams: { lideranca: personSlug },
|
|
})
|
|
expect(pieceRows(filtered.items).map((item) => item.id)).toContain(published.piece.id)
|
|
expect(pieceRows(filtered.items).map((item) => item.id)).not.toContain(draft.piece.id)
|
|
expect(filtered.facets.lideranca).toContainEqual({ value: personSlug, label: contactName })
|
|
|
|
const byName = await loadContentPieceCatalogSearch({ rawSearchParams: { q: contactName } })
|
|
expect(pieceRows(byName.items).map((item) => item.id)).toContain(published.piece.id)
|
|
|
|
// Unpublishing the last piece with the name removes it from the facet.
|
|
await payload.update({
|
|
collection: 'contentPiece',
|
|
id: published.piece.id,
|
|
data: { status: 'rascunho' },
|
|
overrideAccess: true,
|
|
})
|
|
const afterUnpublish = await loadContentPieceCatalogSearch({ rawSearchParams: {} })
|
|
expect(afterUnpublish.facets.lideranca).not.toContainEqual({
|
|
value: personSlug,
|
|
label: contactName,
|
|
})
|
|
})
|
|
|
|
it('serves a published piece file publicly and 404s draft, link and unknown slugs', async () => {
|
|
const marker = `media-${Date.now()}`
|
|
const draft = await createPiece({ title: `Mídia rascunho ${marker}` })
|
|
const published = await createPiece({ title: `Mídia pública ${marker}` })
|
|
const link = await createPiece({
|
|
title: `Mídia link ${marker}`,
|
|
withMedia: false,
|
|
origin: 'instagram',
|
|
sourceUrl: `https://www.instagram.com/reel/MEDIA${Date.now()}/`,
|
|
})
|
|
|
|
await payload.update({
|
|
collection: 'contentPiece',
|
|
id: published.piece.id,
|
|
data: { status: 'publicado' },
|
|
overrideAccess: true,
|
|
})
|
|
await payload.update({
|
|
collection: 'contentPiece',
|
|
id: link.piece.id,
|
|
data: { status: 'publicado' },
|
|
overrideAccess: true,
|
|
})
|
|
|
|
const publishedSlug = (
|
|
await payload.findByID({
|
|
collection: 'contentPiece',
|
|
id: published.piece.id,
|
|
depth: 0,
|
|
overrideAccess: true,
|
|
})
|
|
).slug!
|
|
const draftSlug = (
|
|
await payload.findByID({
|
|
collection: 'contentPiece',
|
|
id: draft.piece.id,
|
|
depth: 0,
|
|
overrideAccess: true,
|
|
})
|
|
).slug
|
|
const linkSlug = (
|
|
await payload.findByID({
|
|
collection: 'contentPiece',
|
|
id: link.piece.id,
|
|
depth: 0,
|
|
overrideAccess: true,
|
|
})
|
|
).slug!
|
|
|
|
const full = await callPublicPieceMedia({ slug: publishedSlug })
|
|
expect(full.status).toBe(200)
|
|
expect(Buffer.from(await full.arrayBuffer())).toEqual(VIDEO_BYTES)
|
|
|
|
const partial = await callPublicPieceMedia({ slug: publishedSlug, range: 'bytes=0-3' })
|
|
expect(partial.status).toBe(206)
|
|
|
|
const download = await callPublicPieceMedia({ slug: publishedSlug, download: true })
|
|
expect(download.headers.get('Content-Disposition')).toContain('attachment')
|
|
// The public download carries the legible name, never the stored upload one.
|
|
expect(download.headers.get('Content-Disposition')).toContain(
|
|
`jorge-solla-1313-${publishedSlug}.mp4`,
|
|
)
|
|
|
|
// A draft has no public slug at all; a link piece has no archived file.
|
|
expect(draftSlug).toBeNull()
|
|
expect((await callPublicPieceMedia({ slug: linkSlug })).status).toBe(404)
|
|
expect((await callPublicPieceMedia({ slug: `nao-existe-${marker}` })).status).toBe(404)
|
|
|
|
// Unpublishing removes the file from the public door immediately.
|
|
await payload.update({
|
|
collection: 'contentPiece',
|
|
id: published.piece.id,
|
|
data: { status: 'rascunho' },
|
|
overrideAccess: true,
|
|
})
|
|
expect((await callPublicPieceMedia({ slug: publishedSlug })).status).toBe(404)
|
|
})
|
|
|
|
it('finds a piece by the expanded theme terms with real evidence (S28)', async () => {
|
|
const marker = `tema-${Date.now()}`
|
|
const term = `atendimento universal ${marker}`
|
|
const match = await createPiece({
|
|
title: `Mutirão no Subúrbio ${marker}`,
|
|
transcript: `Garantir o ${term} na rede pública`,
|
|
status: 'publicado',
|
|
})
|
|
|
|
const expansionCalls: string[] = []
|
|
const data = await loadContentPieceCatalogSearch({
|
|
rawSearchParams: { q: 'defesa do SUS', mode: 'tema' },
|
|
requestHeaders: new Headers({ 'sec-fetch-mode': 'navigate' }),
|
|
expandTheme: async (theme) => {
|
|
expansionCalls.push(theme)
|
|
return { terms: [term] }
|
|
},
|
|
})
|
|
|
|
expect(expansionCalls).toEqual(['defesa do SUS'])
|
|
expect(data.themeUnavailable).toBe(false)
|
|
expect(data.themeApplied).toBe(true)
|
|
const item = pieceRows(data.items).find((row) => row.id === match.piece.id)
|
|
expect(item?.themeMatch?.term).toBe(term)
|
|
expect(item?.themeMatch?.evidence?.quoted).toBe(true)
|
|
expect(item?.themeMatch?.evidence?.parts.some((part) => part.highlighted)).toBe(true)
|
|
})
|
|
|
|
it('degrades to the literal search when the expansion is unavailable (S28)', async () => {
|
|
const marker = `literal-tema-${Date.now()}`
|
|
const match = await createPiece({ title: `Debate na rádio ${marker}`, status: 'publicado' })
|
|
|
|
const data = await loadContentPieceCatalogSearch({
|
|
rawSearchParams: { q: marker, mode: 'tema' },
|
|
requestHeaders: new Headers({ 'sec-fetch-mode': 'navigate' }),
|
|
expandTheme: async () => null,
|
|
})
|
|
|
|
expect(data.themeUnavailable).toBe(true)
|
|
expect(data.themeApplied).toBe(false)
|
|
const item = pieceRows(data.items).find((row) => row.id === match.piece.id)
|
|
expect(item).toBeTruthy()
|
|
expect(item?.themeMatch).toBeNull()
|
|
})
|
|
|
|
it('keeps the literal list without a notice when the expansion adds nothing (S28)', async () => {
|
|
const marker = `vazio-tema-${Date.now()}`
|
|
const match = await createPiece({ title: `Peça ${marker}`, status: 'publicado' })
|
|
|
|
const data = await loadContentPieceCatalogSearch({
|
|
rawSearchParams: { q: marker, mode: 'tema' },
|
|
requestHeaders: new Headers({ 'sec-fetch-mode': 'navigate' }),
|
|
expandTheme: async () => ({ terms: [] }),
|
|
})
|
|
|
|
expect(data.themeUnavailable).toBe(false)
|
|
expect(data.themeApplied).toBe(false)
|
|
expect(pieceRows(data.items).map((row) => row.id)).toEqual([match.piece.id])
|
|
expect(pieceRows(data.items).every((row) => row.themeMatch === null)).toBe(true)
|
|
})
|
|
|
|
it('never expands without navigation signals (S28)', async () => {
|
|
const marker = `inelegivel-${Date.now()}`
|
|
await createPiece({ title: `Peça ${marker}`, status: 'publicado' })
|
|
|
|
let called = false
|
|
const data = await loadContentPieceCatalogSearch({
|
|
rawSearchParams: { q: marker, mode: 'tema' },
|
|
requestHeaders: new Headers(),
|
|
expandTheme: async () => {
|
|
called = true
|
|
return { terms: [marker] }
|
|
},
|
|
})
|
|
|
|
expect(called).toBe(false)
|
|
expect(data.themeUnavailable).toBe(true)
|
|
})
|
|
|
|
it('keeps the literal search untouched without the mode (S28)', async () => {
|
|
const marker = `exato-${Date.now()}`
|
|
const match = await createPiece({ title: `Peça ${marker}`, status: 'publicado' })
|
|
|
|
let called = false
|
|
const data = await loadContentPieceCatalogSearch({
|
|
rawSearchParams: { q: marker },
|
|
requestHeaders: new Headers({ 'sec-fetch-mode': 'navigate' }),
|
|
expandTheme: async () => {
|
|
called = true
|
|
return { terms: [marker] }
|
|
},
|
|
})
|
|
|
|
expect(called).toBe(false)
|
|
expect(data.params.mode).toBeNull()
|
|
expect(data.themeUnavailable).toBe(false)
|
|
expect(pieceRows(data.items).map((row) => row.id)).toEqual([match.piece.id])
|
|
})
|
|
|
|
it('never surfaces a draft in the theme search (S28)', async () => {
|
|
const marker = `rascunho-tema-${Date.now()}`
|
|
const term = `texto sobre ${marker}`
|
|
const draft = await createPiece({
|
|
title: `Rascunho ${marker}`,
|
|
transcript: term,
|
|
})
|
|
const published = await createPiece({
|
|
title: `Publicada ${marker}`,
|
|
transcript: term,
|
|
status: 'publicado',
|
|
})
|
|
|
|
const data = await loadContentPieceCatalogSearch({
|
|
rawSearchParams: { q: `assunto ${marker}`, mode: 'tema' },
|
|
requestHeaders: new Headers({ 'sec-fetch-mode': 'navigate' }),
|
|
expandTheme: async () => ({ terms: [term] }),
|
|
})
|
|
|
|
const ids = pieceRows(data.items).map((row) => row.id)
|
|
expect(ids).toContain(published.piece.id)
|
|
expect(ids).not.toContain(draft.piece.id)
|
|
})
|
|
|
|
it('appends the six card items to a non-empty Central (S38)', async () => {
|
|
const marker = `cards-${Date.now()}`
|
|
const match = await createPiece({ title: `Peça ${marker}`, status: 'publicado' })
|
|
|
|
const data = await loadContentPieceCatalogSearch({ rawSearchParams: {} })
|
|
|
|
// `publishedCount` still counts pieces only: with no filters the board is
|
|
// every published piece plus the six cards — never cards alone.
|
|
const pieces = pieceRows(data.items)
|
|
expect(data.publishedCount).toBe(pieces.length)
|
|
expect(pieces.map((row) => row.id)).toContain(match.piece.id)
|
|
const cards = data.items.filter(isCardCatalogItem)
|
|
expect(cards.map((card) => card.modelId)).toEqual([
|
|
'eu-sou-solla',
|
|
'perfil-quadrado',
|
|
'perfil-retangular',
|
|
'time-de-voce',
|
|
'time-do-estadual',
|
|
'minha-colinha',
|
|
])
|
|
expect(cards.every((card) => card.href.startsWith('/cards?model='))).toBe(true)
|
|
expect(data.facets.tipo).toContainEqual({ value: 'card', label: 'Card' })
|
|
})
|
|
|
|
it('finds the card models by nickname and keeps them out of the geographic facets (S38)', async () => {
|
|
const marker = `cards-busca-${Date.now()}`
|
|
await createPiece({ title: `Peça ${marker}`, status: 'publicado' })
|
|
|
|
const santinho = await loadContentPieceCatalogSearch({ rawSearchParams: { q: 'santinho' } })
|
|
expect(santinho.items.filter(isCardCatalogItem).map((card) => card.modelId)).toEqual([
|
|
'time-de-voce',
|
|
'minha-colinha',
|
|
])
|
|
|
|
const geo = await loadContentPieceCatalogSearch({
|
|
rawSearchParams: { q: 'santinho', cidade: 'salvador' },
|
|
})
|
|
expect(geo.items.filter(isCardCatalogItem)).toEqual([])
|
|
|
|
const byType = await loadContentPieceCatalogSearch({ rawSearchParams: { tipo: 'card' } })
|
|
expect(byType.items.filter(isCardCatalogItem)).toHaveLength(6)
|
|
})
|
|
|
|
describe('C226 — the video still before the play', () => {
|
|
const publish = async (pieceId: number): Promise<string> => {
|
|
await payload.update({
|
|
collection: 'contentPiece',
|
|
id: pieceId,
|
|
data: { status: 'publicado' },
|
|
overrideAccess: true,
|
|
})
|
|
return (
|
|
await payload.findByID({
|
|
collection: 'contentPiece',
|
|
id: pieceId,
|
|
depth: 0,
|
|
overrideAccess: true,
|
|
})
|
|
).slug!
|
|
}
|
|
|
|
const callFrame = (slug: string): Promise<Response> =>
|
|
getPublicPieceFrame(new Request(`http://localhost/conteudos/${slug}/frame`), {
|
|
params: Promise.resolve({ slug }),
|
|
})
|
|
|
|
it('generates the still once, stores it privately and reuses the row', async () => {
|
|
const restoreFfmpeg = withEnv('FFMPEG_PATH', FAKE_FFMPEG)
|
|
try {
|
|
const { piece } = await createPiece({
|
|
title: `Frame ${Date.now()}`,
|
|
mediaData: FRAME_SOURCE_BYTES,
|
|
})
|
|
|
|
const generated = await ensureContentPieceFrame(payload, piece.id)
|
|
if (!generated) throw new Error('the still was not generated')
|
|
expect(generated.filename).toBe(contentPieceFrameFilename(piece.id))
|
|
// The row has to carry the MIME: without it the private-media contract
|
|
// degrades to `application/octet-stream` and forces a download.
|
|
expect(generated.mimeType).toBe('image/jpeg')
|
|
expect(generated.alt).toContain(piece.title)
|
|
const before = (await frameRows()).length
|
|
|
|
// The deterministic name is the cache key: a second call probes, it does
|
|
// not generate again.
|
|
const again = await ensureContentPieceFrame(payload, piece.id)
|
|
expect(again?.id).toBe(generated.id)
|
|
expect(await frameRows()).toHaveLength(before)
|
|
} finally {
|
|
restoreFfmpeg()
|
|
}
|
|
})
|
|
|
|
it('shares one generation between concurrent requests (single flight)', async () => {
|
|
const restoreFfmpeg = withEnv('FFMPEG_PATH', FAKE_FFMPEG)
|
|
try {
|
|
const { piece } = await createPiece({
|
|
title: `Frame concorrência ${Date.now()}`,
|
|
mediaData: FRAME_SOURCE_BYTES,
|
|
})
|
|
|
|
const [first, second] = await Promise.all([
|
|
ensureContentPieceFrame(payload, piece.id),
|
|
ensureContentPieceFrame(payload, piece.id),
|
|
])
|
|
|
|
expect(second?.id).toBe(first?.id)
|
|
const rows = (await frameRows()).filter(
|
|
(row) => row.filename === contentPieceFrameFilename(piece.id),
|
|
)
|
|
expect(rows).toHaveLength(1)
|
|
} finally {
|
|
restoreFfmpeg()
|
|
}
|
|
})
|
|
|
|
it('memoizes a failure instead of extracting the still on every view', async () => {
|
|
const restoreFfmpeg = withEnv('FFMPEG_PATH', FAKE_FFMPEG)
|
|
const restoreFail = withEnv('FAKE_FFMPEG_FAIL', '1')
|
|
try {
|
|
const { piece } = await createPiece({
|
|
title: `Frame falha ${Date.now()}`,
|
|
mediaData: FRAME_SOURCE_BYTES,
|
|
})
|
|
|
|
expect(await ensureContentPieceFrame(payload, piece.id)).toBeNull()
|
|
expect(await findContentPieceFrameMedia(payload, piece.id)).toBeNull()
|
|
|
|
// Second view: the failure is remembered, so nothing is created and the
|
|
// card keeps its neutral slot.
|
|
expect(await ensureContentPieceFrame(payload, piece.id)).toBeNull()
|
|
expect(await findContentPieceFrameMedia(payload, piece.id)).toBeNull()
|
|
} finally {
|
|
restoreFail()
|
|
restoreFfmpeg()
|
|
}
|
|
})
|
|
|
|
it('never caches a still for a piece with no archived file or an unknown id', async () => {
|
|
const restoreFfmpeg = withEnv('FFMPEG_PATH', FAKE_FFMPEG)
|
|
const argvLog = join(tmpdir(), `frame-argv-${Date.now()}.json`)
|
|
const restoreLog = withEnv('FAKE_FFMPEG_LOG', argvLog)
|
|
try {
|
|
const { piece } = await createPiece({
|
|
title: `Frame sem arquivo ${Date.now()}`,
|
|
withMedia: false,
|
|
sourceUrl: 'https://www.instagram.com/reel/SEMARQUIVO/',
|
|
})
|
|
const before = (await frameRows()).length
|
|
|
|
expect(await ensureContentPieceFrame(payload, piece.id)).toBeNull()
|
|
expect(await ensureContentPieceFrame(payload, -1)).toBeNull()
|
|
expect(await ensureContentPieceFrame(payload, Number.NaN)).toBeNull()
|
|
expect(await frameRows()).toHaveLength(before)
|
|
// A peça-link has no file to read: ffmpeg is never even spawned.
|
|
expect(existsSync(argvLog)).toBe(false)
|
|
} finally {
|
|
rmSync(argvLog, { force: true })
|
|
restoreLog()
|
|
restoreFfmpeg()
|
|
}
|
|
})
|
|
|
|
it('skips a source the self-heal would have to download whole', async () => {
|
|
const restoreFfmpeg = withEnv('FFMPEG_PATH', FAKE_FFMPEG)
|
|
try {
|
|
const { piece, media } = await createPiece({
|
|
title: `Frame gigante ${Date.now()}`,
|
|
mediaData: FRAME_SOURCE_BYTES,
|
|
})
|
|
if (!media) throw new Error('fixture media was not created')
|
|
await payload.update({
|
|
collection: 'contentMedia',
|
|
id: media.id,
|
|
data: { filesize: CONTENT_PIECE_FRAME_MAX_SOURCE_BYTES + 1 },
|
|
overrideAccess: true,
|
|
})
|
|
|
|
// The heal path is capped: a full-length recording is not worth a
|
|
// visitor's connection. The refusal is remembered like a failure, so a
|
|
// later view does not re-download it either.
|
|
expect(await ensureContentPieceFrame(payload, piece.id)).toBeNull()
|
|
expect(await ensureContentPieceFrame(payload, piece.id)).toBeNull()
|
|
expect(await findContentPieceFrameMedia(payload, piece.id)).toBeNull()
|
|
} finally {
|
|
restoreFfmpeg()
|
|
}
|
|
})
|
|
|
|
it('extracts the still from the file the job already has, whatever its size', async () => {
|
|
const restoreFfmpeg = withEnv('FFMPEG_PATH', FAKE_FFMPEG)
|
|
try {
|
|
const { piece, media } = await createPiece({
|
|
title: `Frame local ${Date.now()}`,
|
|
mediaData: FRAME_SOURCE_BYTES,
|
|
})
|
|
if (!media) throw new Error('fixture media was not created')
|
|
await payload.update({
|
|
collection: 'contentMedia',
|
|
id: media.id,
|
|
data: { filesize: CONTENT_PIECE_FRAME_MAX_SOURCE_BYTES + 1 },
|
|
overrideAccess: true,
|
|
})
|
|
|
|
// The cap is about the cost of the DOWNLOAD, and the job path has
|
|
// nothing to download: the C211 pipeline already wrote that file.
|
|
await withTempDir(async (tempDir) => {
|
|
const local = join(tempDir, 'source.mp4')
|
|
await writeFile(local, FRAME_SOURCE_BYTES)
|
|
const generated = await ensureContentPieceFrame(payload, piece.id, { localPath: local })
|
|
expect(generated?.filename).toBe(contentPieceFrameFilename(piece.id))
|
|
})
|
|
} finally {
|
|
restoreFfmpeg()
|
|
}
|
|
})
|
|
|
|
it('serves the still of a published video and 404s everything else', async () => {
|
|
const restoreFfmpeg = withEnv('FFMPEG_PATH', FAKE_FFMPEG)
|
|
try {
|
|
const marker = `frame-rota-${Date.now()}`
|
|
const published = await createPiece({
|
|
title: `Frame pública ${marker}`,
|
|
mediaData: FRAME_SOURCE_BYTES,
|
|
})
|
|
const photo = await createPiece({
|
|
title: `Frame foto ${marker}`,
|
|
type: 'foto',
|
|
mediaMimetype: 'image/png',
|
|
mediaData: FRAME_SOURCE_BYTES,
|
|
})
|
|
const link = await createPiece({
|
|
title: `Frame link ${marker}`,
|
|
withMedia: false,
|
|
origin: 'instagram',
|
|
sourceUrl: `https://www.instagram.com/reel/ROTA${Date.now()}/`,
|
|
})
|
|
|
|
const publishedSlug = await publish(published.piece.id)
|
|
const photoSlug = await publish(photo.piece.id)
|
|
const linkSlug = await publish(link.piece.id)
|
|
|
|
const served = await callFrame(publishedSlug)
|
|
expect(served.status).toBe(200)
|
|
expect(served.headers.get('Content-Type')).toBe('image/jpeg')
|
|
// The derived object is as private as the video it comes from.
|
|
expect(served.headers.get('Cache-Control')).toBe('private, no-store')
|
|
expect(Buffer.from(await served.arrayBuffer())).toEqual(FRAME_SOURCE_BYTES)
|
|
|
|
// A photo, a link piece and an unknown slug never reach the generation.
|
|
expect((await callFrame(photoSlug)).status).toBe(404)
|
|
expect((await callFrame(linkSlug)).status).toBe(404)
|
|
expect((await callFrame(`nao-existe-${marker}`)).status).toBe(404)
|
|
|
|
// The kill switch closes the frame door with the video door.
|
|
await payload.update({
|
|
collection: 'contentPiece',
|
|
id: published.piece.id,
|
|
data: { status: 'rascunho' },
|
|
overrideAccess: true,
|
|
})
|
|
expect((await callFrame(publishedSlug)).status).toBe(404)
|
|
} finally {
|
|
restoreFfmpeg()
|
|
}
|
|
})
|
|
|
|
it('declares the frame in the public projection only for a video with a file', async () => {
|
|
const video = await createPiece({
|
|
title: `Frame projeção vídeo ${Date.now()}`,
|
|
mediaData: FRAME_SOURCE_BYTES,
|
|
})
|
|
const photo = await createPiece({
|
|
title: `Frame projeção foto ${Date.now()}`,
|
|
type: 'foto',
|
|
mediaMimetype: 'image/png',
|
|
mediaData: FRAME_SOURCE_BYTES,
|
|
})
|
|
await publish(video.piece.id)
|
|
await publish(photo.piece.id)
|
|
|
|
const byId = async (pieceId: number) =>
|
|
(await getPublishedContentPieceItems()).find((item) => item.id === pieceId)
|
|
|
|
expect((await byId(video.piece.id))?.framePath).toBe(
|
|
`/conteudos/${(await byId(video.piece.id))?.slug}/frame`,
|
|
)
|
|
expect((await byId(photo.piece.id))?.framePath).toBeNull()
|
|
})
|
|
|
|
it('leaves the piece ready when the still cannot be extracted (failure isolation)', async () => {
|
|
const { piece } = await createPiece({
|
|
processingStatus: 'processando',
|
|
title: 'Frame com ffmpeg quebrado',
|
|
mediaData: FRAME_SOURCE_BYTES,
|
|
})
|
|
// The encoder works; only the still extraction fails.
|
|
const restoreFfmpeg = withEnv('FFMPEG_PATH', FAKE_FFMPEG)
|
|
const restoreSegments = withEnv('FAKE_FFMPEG_SEGMENTS', '1')
|
|
const restoreStill = withEnv('FAKE_FFMPEG_FAIL_STILL', '1')
|
|
try {
|
|
await runContentPieceJob(payload, piece.id, {
|
|
transcribe: transcribeOk,
|
|
catalog: catalogStub,
|
|
})
|
|
} finally {
|
|
restoreStill()
|
|
restoreSegments()
|
|
restoreFfmpeg()
|
|
}
|
|
|
|
const updated = await payload.findByID({
|
|
collection: 'contentPiece',
|
|
id: piece.id,
|
|
depth: 0,
|
|
overrideAccess: true,
|
|
})
|
|
expect(updated.processingStatus).toBe('pronto')
|
|
expect(updated.error).toBeNull()
|
|
expect(updated.step).toBeNull()
|
|
// A frame is a cache entry: the ingestion result never depends on it.
|
|
expect(await findContentPieceFrameMedia(payload, piece.id)).toBeNull()
|
|
})
|
|
|
|
it.skipIf(!hasFfmpeg)('extracts a real JPEG one second into the video', async () => {
|
|
const dir = join(tmpdir(), 'content-piece-frame-real')
|
|
const restoreFfmpeg = withEnv('FFMPEG_PATH', undefined)
|
|
try {
|
|
await rm(dir, { recursive: true, force: true })
|
|
await mkdir(dir, { recursive: true })
|
|
const source = join(dir, 'source.mp4')
|
|
const output = join(dir, contentPieceFrameFilename(1))
|
|
const encoded = spawnSync('ffmpeg', [
|
|
'-nostdin',
|
|
'-hide_banner',
|
|
'-y',
|
|
'-f',
|
|
'lavfi',
|
|
'-i',
|
|
'testsrc=size=320x240:rate=10:duration=4',
|
|
source,
|
|
])
|
|
expect(encoded.status).toBe(0)
|
|
|
|
for (const atSeconds of CONTENT_PIECE_FRAME_SEEK_ATTEMPTS) {
|
|
await runFfmpeg(
|
|
buildContentPieceFrameFfmpegArgs({ inputPath: source, outputPath: output, atSeconds }),
|
|
4,
|
|
)
|
|
expect(readFileSync(output).subarray(0, 2)).toEqual(Buffer.from([0xff, 0xd8]))
|
|
await rm(output, { force: true })
|
|
}
|
|
} finally {
|
|
restoreFfmpeg()
|
|
await rm(dir, { recursive: true, force: true })
|
|
}
|
|
})
|
|
})
|
|
})
|