teqo-backup/tests/int/speechCut.int.spec.ts

1224 lines
41 KiB
TypeScript

// @vitest-environment node
import { spawnSync } from 'node:child_process'
import { randomUUID } from 'node:crypto'
import { mkdtempSync, readFileSync, rmSync } from 'node:fs'
import { tmpdir } from 'node:os'
import { join, resolve } from 'node:path'
import { fileURLToPath } from 'node:url'
import type { Payload } from 'payload'
import { getPayload } from 'payload'
import { afterAll, afterEach, beforeAll, describe, expect, it, vi } from 'vitest'
vi.mock('@/utilities/speech/speechCutScheduler', () => ({
startSpeechCutJobInBackground: vi.fn(),
}))
// The SpeechCut afterChange hook revalidates its document tag, which needs the
// Next runtime — neuter it (the revalidation itself is pinned elsewhere). The
// acervo loader also reaches `unstable_cache` through the C172 YouTube anchor,
// so the mock answers it with the uncached function.
vi.mock('next/cache', () => ({
revalidatePath: () => undefined,
revalidateTag: () => undefined,
unstable_cache: (fn: unknown) => fn,
}))
vi.mock('@/utilities/campaignActionContext', async (importOriginal) => {
const original = await importOriginal<typeof import('@/utilities/campaignActionContext')>()
return { ...original, getCampaignActionContext: vi.fn() }
})
import {
deleteSpeechCutForActor,
getSpeechCutStatusForActor,
retrySpeechCutForActor,
saveSpeechCutForActor,
setSpeechCutPublishedForActor,
suggestSpeechCutMetadataForActor,
updateSpeechCutTextForActor,
} from '@/app/(campaign)/campanha/actions/speech'
import {
SPEECH_CUT_FORBIDDEN_MESSAGE,
SPEECH_CUT_MIRROR_MISSING_MESSAGE,
SPEECH_CUT_PUBLISH_NOT_READY_MESSAGE,
SPEECH_CUT_RETRY_NOT_FAILED_MESSAGE,
} from '@/lib/schemas/speechCut'
import {
SPEECH_CUT_FAILURE_INTERRUPTED,
SPEECH_CUT_FAILURE_MIRROR_MISSING,
SPEECH_CUT_FAILURE_UNAVAILABLE,
} from '@/lib/speechCut'
import { INTERNET_SPEECH_MEDIA_SLUG } from '@/lib/webSpeech'
import type { CampaignUser } from '@/payload-types'
import config from '@/payload.config'
import { getCampaignActionContext } from '@/utilities/campaignActionContext'
import {
reapStaleSpeechCut,
runSpeechCutJob,
SPEECH_CUT_STALE_MS,
} from '@/utilities/speech/speechCutJob'
import {
loadSpeechCutAcervoPageData,
loadSpeechCutDetailPageData,
loadSpeechCutOriginSpeechIds,
loadSpeechCutsForSpeech,
loadSpeechCutsForSpeeches,
SpeechCutNotFoundError,
} from '@/utilities/speech/speechCutPageData'
import { startSpeechCutJobInBackground } from '@/utilities/speech/speechCutScheduler'
import { upsertSpeechBundle, type SpeechImportBundle } from '@/utilities/speech/speechImport'
import { loadSpeechAcervoPageData } from '@/utilities/speech/speechPageData'
import {
camaraFetchStub,
camaraVodStub,
DOWNLOAD_URL,
MP4_BYTES,
PLAYBACK_URL,
} from '../helpers/camaraVodStub'
import { installCampaignFixtures } from '../helpers/campaignFixtures'
import { speechBundleFixture } from '../helpers/speechBundleFixture'
import {
createInternetSpeechMedia,
WEB_SPEECH_MP3_BYTES,
WEB_SPEECH_MP4_BYTES,
} from '../helpers/webSpeechMediaFixture'
const FAKE_FFMPEG = fileURLToPath(new URL('../fixtures/fake-ffmpeg.mjs', import.meta.url))
const hasBinary = (name: string): boolean => spawnSync(name, ['-version']).status === 0
const hasFfmpeg = hasBinary('ffmpeg') && hasBinary('ffprobe')
let payload: Payload
const createdSourceKeys = new Set<string>()
const createdCutIds = new Set<number>()
const createdMediaIds = new Set<number>()
const createdMirrorMediaIds = new Set<number>()
const campaignFixtures = installCampaignFixtures({
getPayload: () => payload,
setPayload: (nextPayload) => {
payload = nextPayload
},
})
const mockedGetContext = vi.mocked(getCampaignActionContext)
const mockedStartJob = vi.mocked(startSpeechCutJobInBackground)
const createSpeech = async (overrides: Partial<SpeechImportBundle> = {}): Promise<number> => {
const bundle = speechBundleFixture({
vodPlaybackUrl: PLAYBACK_URL,
vodDownloadUrl: DOWNLOAD_URL,
...overrides,
})
createdSourceKeys.add(bundle.sourceKey)
await upsertSpeechBundle(payload, bundle)
const found = await payload.find({
collection: 'speech',
where: { sourceKey: { equals: bundle.sourceKey } },
depth: 0,
limit: 1,
overrideAccess: true,
})
const speech = found.docs[0]
if (!speech) throw new Error('fixture speech was not created')
return speech.id
}
const createCut = async (data: {
speech: number
startSeconds: number
endSeconds: number
status?: 'processing' | 'published' | 'unpublished' | 'failed'
createdBy?: number
title?: string
description?: string
}): Promise<number> => {
const cut = await payload.create({
collection: 'speechCut',
data: {
speech: data.speech,
startSeconds: data.startSeconds,
endSeconds: data.endSeconds,
durationSeconds: data.endSeconds - data.startSeconds,
title: data.title ?? `Corte ${randomUUID().slice(0, 8)}`,
description: data.description ?? 'Descrição do corte',
status: data.status ?? 'processing',
...(data.createdBy ? { createdBy: data.createdBy } : {}),
},
depth: 0,
overrideAccess: true,
})
createdCutIds.add(cut.id)
return cut.id
}
/** C217 — one mirror artifact of a web speech (caller may reuse across cuts). */
const createMirrorMedia = async (bytes: Buffer, filename: string): Promise<number> => {
const mediaId = await createInternetSpeechMedia(payload, bytes, filename)
createdMirrorMediaIds.add(mediaId)
return mediaId
}
/** C217 — a web speech bundle: no Câmara coordinates, optional private mirror. */
const createWebSpeech = async ({
mirrorMedia = null,
...overrides
}: Partial<SpeechImportBundle> & { mirrorMedia?: number | null } = {}): Promise<number> => {
const sourceKey = `web:test:${randomUUID()}`
const bundle = speechBundleFixture({
sourceKey,
origin: 'web',
platform: 'youtube',
externalId: sourceKey,
sourceUrl: `https://www.youtube.com/watch?v=${encodeURIComponent(sourceKey)}`,
title: 'Fala publicada na internet',
channel: 'Canal do teste',
mirroredMedia: mirrorMedia,
// Câmara-only coordinates never travel on a web row.
legislature: null,
type: null,
phase: null,
eventId: null,
audioId: null,
excerptTMs: null,
vodPlaybackUrl: null,
vodDownloadUrl: null,
officialTextUrl: null,
officialTranscript: null,
summary: null,
presidingOfficer: null,
...overrides,
})
createdSourceKeys.add(bundle.sourceKey)
await upsertSpeechBundle(payload, bundle)
const found = await payload.find({
collection: 'speech',
where: { sourceKey: { equals: bundle.sourceKey } },
depth: 0,
limit: 1,
overrideAccess: true,
})
const speech = found.docs[0]
if (!speech) throw new Error('web speech fixture was not created')
return speech.id
}
/** Asserts the job published the cut with the fake ffmpeg's copy of the source. */
const expectPublishedWithFakeFfmpeg = async (cutId: number, sourceBytes: Buffer = MP4_BYTES) => {
const cut = await payload.findByID({
collection: 'speechCut',
id: cutId,
depth: 1,
overrideAccess: true,
})
expect(cut.status).toBe('published')
const media = typeof cut.media === 'object' && cut.media !== null ? cut.media : null
if (!media) throw new Error('media was not attached')
createdMediaIds.add(media.id)
expect(readFileSync(resolve(process.cwd(), 'media', media.filename as string))).toEqual(
sourceBytes,
)
return { cut, media }
}
const createUsers = async () => {
const fixtures = campaignFixtures()
const [communicator, coordinator, candidate, advisor, leader] = await Promise.all([
fixtures.createCampaignUser('communicator'),
fixtures.createCampaignUser('coordinator'),
fixtures.createCampaignUser('candidate'),
fixtures.createCampaignUser('advisor'),
fixtures.createCampaignUser('leader'),
])
return { communicator, coordinator, candidate, advisor, leader }
}
const findCutsAs = (user: CampaignUser | undefined, ids: readonly number[]) =>
payload.find({
collection: 'speechCut',
where: { id: { in: [...ids] } },
depth: 0,
limit: 0,
pagination: false,
...(user ? { user } : {}),
overrideAccess: false,
})
const findCutById = async (id: number) => {
const result = await payload.find({
collection: 'speechCut',
where: { id: { equals: id } },
depth: 0,
limit: 1,
overrideAccess: true,
})
return result.docs[0] ?? null
}
const asActor = (actor: CampaignUser) => mockedGetContext.mockResolvedValue({ payload, actor })
const withFfmpegPath = (value: string | undefined): (() => void) => {
const previous = process.env.FFMPEG_PATH
if (value === undefined) delete process.env.FFMPEG_PATH
else process.env.FFMPEG_PATH = value
return () => {
if (previous === undefined) delete process.env.FFMPEG_PATH
else process.env.FFMPEG_PATH = previous
}
}
describe('speech cuts (C167)', () => {
beforeAll(async () => {
payload = await getPayload({ config: await config })
})
afterEach(() => {
mockedGetContext.mockReset()
mockedStartJob.mockReset()
})
afterAll(async () => {
for (const mediaId of createdMediaIds) {
await payload
.delete({ collection: 'media', id: mediaId, overrideAccess: true })
.catch(() => undefined)
}
for (const cutId of createdCutIds) {
await payload
.delete({ collection: 'speechCut', id: cutId, overrideAccess: true })
.catch(() => undefined)
}
for (const sourceKey of createdSourceKeys) {
const found = await payload.find({
collection: 'speech',
where: { sourceKey: { equals: sourceKey } },
depth: 0,
limit: 1,
overrideAccess: true,
})
const speech = found.docs[0]
if (speech) {
await payload
.delete({ collection: 'speech', id: speech.id, depth: 0, overrideAccess: true })
.catch(() => undefined)
}
}
// The speech cascade already removes its mirror; this catches leftovers.
for (const mirrorId of createdMirrorMediaIds) {
await payload
.delete({
collection: INTERNET_SPEECH_MEDIA_SLUG,
id: mirrorId,
depth: 0,
overrideAccess: true,
})
.catch(() => undefined)
}
})
it('exposes published cuts to anonymous readers and every status to the acervo gate', async () => {
const speech = await createSpeech()
const published = await createCut({
speech,
startSeconds: 0,
endSeconds: 20,
status: 'published',
})
const unpublished = await createCut({
speech,
startSeconds: 30,
endSeconds: 50,
status: 'unpublished',
})
const failed = await createCut({ speech, startSeconds: 60, endSeconds: 80, status: 'failed' })
const ids = [published, unpublished, failed]
const { communicator, advisor, leader } = await createUsers()
const anonymous = await findCutsAs(undefined, ids)
expect(anonymous.docs.map((doc) => doc.id)).toEqual([published])
const asAdvisor = await findCutsAs(advisor, ids)
expect(asAdvisor.docs.map((doc) => doc.id)).toEqual([published])
const asLeader = await findCutsAs(leader, ids)
expect(asLeader.docs.map((doc) => doc.id)).toEqual([published])
const asCommunicator = await findCutsAs(communicator, ids)
expect(asCommunicator.docs).toHaveLength(3)
})
it('creates the cut, normalizes the range and dedupes a double submit', async () => {
const speech = await createSpeech()
const { communicator } = await createUsers()
asActor(communicator)
const first = await saveSpeechCutForActor({
speechId: speech,
startSeconds: 43,
endSeconds: 118,
title: 'Acesso a medicamentos',
description: 'Trecho do pronunciamento',
})
expect(first.status).toBe('processing')
expect(first.step).toBe('resolving')
expect(first.publicPath).toBe(`/corte/${first.id}`)
expect(first.durationSeconds).toBe(75)
createdCutIds.add(first.id)
expect(mockedStartJob).toHaveBeenCalledTimes(1)
const second = await saveSpeechCutForActor({
speechId: speech,
startSeconds: 43,
endSeconds: 118,
title: 'Outro título',
description: 'Outra descrição',
})
expect(second.id).toBe(first.id)
expect(mockedStartJob).toHaveBeenCalledTimes(1)
})
it('rejects a selection on a speech too short and denies advisor/leader fail-closed', async () => {
const shortSpeech = await createSpeech({ durationSeconds: 3 })
const speech = await createSpeech()
const { communicator, advisor, leader } = await createUsers()
asActor(communicator)
await expect(
saveSpeechCutForActor({
speechId: shortSpeech,
startSeconds: 0,
endSeconds: 5,
title: 't',
description: 'd',
}),
).rejects.toThrow(/5 segundos até o fim da fala/)
for (const denied of [advisor, leader]) {
asActor(denied)
await expect(
saveSpeechCutForActor({
speechId: speech,
startSeconds: 0,
endSeconds: 30,
title: 't',
description: 'd',
}),
).rejects.toThrow(SPEECH_CUT_FORBIDDEN_MESSAGE)
}
})
it('accepts a long selection up to the speech duration (C170, no 180 s cap)', async () => {
const speech = await createSpeech()
const { communicator } = await createUsers()
asActor(communicator)
const long = await saveSpeechCutForActor({
speechId: speech,
startSeconds: 0,
endSeconds: 240,
title: 'Discurso inteiro',
description: 'Trecho longo sem teto',
})
expect(long.durationSeconds).toBe(240)
createdCutIds.add(long.id)
await expect(
saveSpeechCutForActor({
speechId: speech,
startSeconds: 0,
endSeconds: 3,
title: 't',
description: 'd',
}),
).rejects.toThrow(/5 segundos até o fim da fala/)
})
it('retries only a failed row, reusing the same id', async () => {
const speech = await createSpeech()
const { communicator } = await createUsers()
const failed = await createCut({
speech,
startSeconds: 10,
endSeconds: 40,
status: 'failed',
createdBy: communicator.id,
})
const published = await createCut({
speech,
startSeconds: 60,
endSeconds: 90,
status: 'published',
})
asActor(communicator)
const retried = await saveSpeechCutForActor({ retryOf: failed })
expect(retried.id).toBe(failed)
expect(retried.status).toBe('processing')
expect(retried.step).toBe('resolving')
expect(mockedStartJob).toHaveBeenCalledTimes(1)
await expect(saveSpeechCutForActor({ retryOf: published })).rejects.toThrow(/corte que falhou/)
})
it('reports the status through the poll and reaps a stale processing row', async () => {
const speech = await createSpeech()
const { communicator } = await createUsers()
const cutId = await createCut({ speech, startSeconds: 0, endSeconds: 30 })
asActor(communicator)
const current = await getSpeechCutStatusForActor({ cutId })
expect(current.status).toBe('processing')
const reaped = await reapStaleSpeechCut(payload, {
id: cutId,
status: 'processing',
updatedAt: new Date(Date.now() - SPEECH_CUT_STALE_MS - 60_000).toISOString(),
})
expect(reaped).toBe(true)
const after = await getSpeechCutStatusForActor({ cutId })
expect(after.status).toBe('failed')
expect(after.failureMessage).toBe(SPEECH_CUT_FAILURE_INTERRUPTED)
})
it('suggests the deterministic fallback when the AI is unavailable', async () => {
const speech = await createSpeech()
const { communicator } = await createUsers()
asActor(communicator)
const previousKey = process.env.DEEPSEEK_API_KEY
delete process.env.DEEPSEEK_API_KEY
try {
const suggestion = await suggestSpeechCutMetadataForActor({
speechId: speech,
startSeconds: 0,
endSeconds: 30,
})
expect(suggestion.source).toBe('fallback')
expect(suggestion.title).toBe('Trecho de BREVES COMUNICAÇÕES — 07/02/2023')
expect(suggestion.description).toBe('Saúde e educação em Feira de Santana.')
} finally {
if (previousKey !== undefined) process.env.DEEPSEEK_API_KEY = previousKey
}
})
it('publishes the cut with a fake ffmpeg: media stored, same title, same file', async () => {
const speech = await createSpeech()
const cutId = await createCut({ speech, startSeconds: 43, endSeconds: 118 })
vi.stubGlobal('fetch', camaraFetchStub(MP4_BYTES))
const restoreFfmpeg = withFfmpegPath(FAKE_FFMPEG)
try {
await runSpeechCutJob(payload, cutId)
const { cut, media } = await expectPublishedWithFakeFfmpeg(cutId)
expect(cut.step).toBeNull()
expect(cut.publishedAt).toBeTruthy()
expect(media.filename).toBe(`corte-${cutId}-43-118.mp4`)
expect(media.alt).toBe(cut.title)
} finally {
restoreFfmpeg()
vi.unstubAllGlobals()
}
})
it('fails closed when ffmpeg fails: nothing published, no media', async () => {
const speech = await createSpeech()
const cutId = await createCut({ speech, startSeconds: 0, endSeconds: 30 })
vi.stubGlobal('fetch', camaraFetchStub(MP4_BYTES))
const restoreFfmpeg = withFfmpegPath(FAKE_FFMPEG)
const previousFail = process.env.FAKE_FFMPEG_FAIL
process.env.FAKE_FFMPEG_FAIL = '1'
try {
await runSpeechCutJob(payload, cutId)
const cut = await payload.findByID({
collection: 'speechCut',
id: cutId,
depth: 1,
overrideAccess: true,
})
expect(cut.status).toBe('failed')
expect(cut.media ?? null).toBeNull()
expect(cut.publishedAt ?? null).toBeNull()
expect(cut.error).toBeTruthy()
// C169 — the failing step survives so the dialog can say "cortar", not a generic line.
expect(cut.step).toBe('cutting')
} finally {
if (previousFail === undefined) delete process.env.FAKE_FFMPEG_FAIL
else process.env.FAKE_FFMPEG_FAIL = previousFail
restoreFfmpeg()
vi.unstubAllGlobals()
}
})
it('publishes with the stored link when the API answers INDISPONIVEL', async () => {
const speech = await createSpeech()
const cutId = await createCut({ speech, startSeconds: 43, endSeconds: 118 })
vi.stubGlobal('fetch', camaraVodStub({ apiState: 'INDISPONIVEL' }))
const restoreFfmpeg = withFfmpegPath(FAKE_FFMPEG)
try {
await runSpeechCutJob(payload, cutId)
await expectPublishedWithFakeFfmpeg(cutId)
} finally {
restoreFfmpeg()
vi.unstubAllGlobals()
}
})
it('publishes with the stored link when the API PRONTO hash is dead', async () => {
const speech = await createSpeech()
const cutId = await createCut({ speech, startSeconds: 43, endSeconds: 118 })
const deadApiPlayback = 'https://cdn.camara.leg.br/hash-velho.mp4'
const deadApiDownload = 'https://cdn.camara.leg.br/hash-velho-download.mp4'
const fetched: string[] = []
const stub = camaraVodStub({
apiPlaybackUrl: deadApiPlayback,
apiDownloadUrl: deadApiDownload,
deadUrls: [deadApiPlayback, deadApiDownload],
})
vi.stubGlobal('fetch', (async (input: RequestInfo | URL, init?: RequestInit) => {
fetched.push(String(input))
return stub(input, init)
}) as typeof fetch)
const restoreFfmpeg = withFfmpegPath(FAKE_FFMPEG)
try {
await runSpeechCutJob(payload, cutId)
await expectPublishedWithFakeFfmpeg(cutId)
// The dead hash was tried and refused; the stored cache fed the cut.
expect(fetched).toContain(deadApiPlayback)
expect(fetched).toContain(PLAYBACK_URL)
} finally {
restoreFfmpeg()
vi.unstubAllGlobals()
}
})
it('fails with the honest cause and keeps the step when nothing is recoverable', async () => {
const speech = await createSpeech()
const cutId = await createCut({ speech, startSeconds: 0, endSeconds: 30 })
vi.stubGlobal(
'fetch',
camaraVodStub({ apiState: 'INDISPONIVEL', deadUrls: [PLAYBACK_URL, DOWNLOAD_URL] }),
)
try {
await runSpeechCutJob(payload, cutId)
const cut = await payload.findByID({
collection: 'speechCut',
id: cutId,
depth: 0,
overrideAccess: true,
})
expect(cut.status).toBe('failed')
expect(cut.step).toBe('resolving')
expect(cut.error).toBe(SPEECH_CUT_FAILURE_UNAVAILABLE)
const { communicator } = await createUsers()
asActor(communicator)
const poll = await getSpeechCutStatusForActor({ cutId })
expect(poll.status).toBe('failed')
expect(poll.failureMessage).toBe('A Câmara não disponibiliza mais o arquivo deste trecho.')
} finally {
vi.unstubAllGlobals()
}
})
describe.skipIf(!hasFfmpeg)('with the real ffmpeg (CI)', () => {
it('cuts exactly the picked [start, end] window', async () => {
const workdir = mkdtempSync(join(tmpdir(), 'speech-cut-real-'))
const source = join(workdir, 'source.mp4')
const generated = spawnSync(
'ffmpeg',
[
'-nostdin',
'-hide_banner',
'-y',
'-f',
'lavfi',
'-i',
'testsrc=duration=30:size=128x72:rate=10',
'-pix_fmt',
'yuv420p',
source,
],
{ encoding: 'utf8' },
)
expect(generated.status).toBe(0)
const speech = await createSpeech()
const cutId = await createCut({ speech, startSeconds: 10, endSeconds: 16 })
vi.stubGlobal('fetch', camaraFetchStub(readFileSync(source)))
const restoreFfmpeg = withFfmpegPath(undefined)
try {
await runSpeechCutJob(payload, cutId)
const cut = await payload.findByID({
collection: 'speechCut',
id: cutId,
depth: 1,
overrideAccess: true,
})
expect(cut.status).toBe('published')
const media = typeof cut.media === 'object' && cut.media !== null ? cut.media : null
if (!media) throw new Error('media was not attached')
createdMediaIds.add(media.id)
expect(media.mimeType).toBe('video/mp4')
const probe = spawnSync(
'ffprobe',
[
'-v',
'error',
'-show_entries',
'format=duration',
'-of',
'default=noprint_wrappers=1:nokey=1',
resolve(process.cwd(), 'media', media.filename as string),
],
{ encoding: 'utf8' },
)
expect(probe.status).toBe(0)
expect(Number(probe.stdout.trim())).toBeCloseTo(6, 1)
} finally {
restoreFfmpeg()
vi.unstubAllGlobals()
rmSync(workdir, { recursive: true, force: true })
}
})
})
// C217 — the web source: the cut reads the private mirror, never the Câmara.
describe('web speech cuts (C217)', () => {
it('publishes from the private mirror without ever touching the Câmara', async () => {
const mirror = await createMirrorMedia(WEB_SPEECH_MP4_BYTES, 'source.web.mp4')
const speech = await createWebSpeech({ mirrorMedia: mirror })
const cutId = await createCut({ speech, startSeconds: 43, endSeconds: 118 })
const fetchSpy = vi.fn(() => {
throw new Error('a web cut must not reach the Câmara')
})
vi.stubGlobal('fetch', fetchSpy)
const restoreFfmpeg = withFfmpegPath(FAKE_FFMPEG)
try {
await runSpeechCutJob(payload, cutId)
const { cut, media } = await expectPublishedWithFakeFfmpeg(cutId, WEB_SPEECH_MP4_BYTES)
expect(cut.step).toBeNull()
expect(cut.publishedAt).toBeTruthy()
expect(media.filename).toBe(`corte-${cutId}-43-118.mp4`)
expect(fetchSpy).not.toHaveBeenCalled()
} finally {
restoreFfmpeg()
vi.unstubAllGlobals()
}
})
it('refuses a web speech without a mirror and fails the row honestly', async () => {
const speech = await createWebSpeech()
const actors = await createUsers()
await asActor(actors.communicator)
await expect(
saveSpeechCutForActor({
speechId: speech,
startSeconds: 0,
endSeconds: 30,
title: 'Corte',
description: 'Descrição',
}),
).rejects.toThrow(SPEECH_CUT_MIRROR_MISSING_MESSAGE)
// A row created out-of-band still fails honestly in the job.
const cutId = await createCut({ speech, startSeconds: 0, endSeconds: 30 })
vi.stubGlobal('fetch', () => {
throw new Error('a web cut must not reach the Câmara')
})
const restoreFfmpeg = withFfmpegPath(FAKE_FFMPEG)
try {
await runSpeechCutJob(payload, cutId)
const cut = await payload.findByID({
collection: 'speechCut',
id: cutId,
depth: 0,
overrideAccess: true,
})
expect(cut.status).toBe('failed')
expect(cut.error).toBe(SPEECH_CUT_FAILURE_MIRROR_MISSING)
expect(cut.media ?? null).toBeNull()
expect(cut.publishedAt ?? null).toBeNull()
} finally {
restoreFfmpeg()
vi.unstubAllGlobals()
}
})
it('cuts an audio mirror with the audio ffmpeg variant (no video stream)', async () => {
const logDir = mkdtempSync(join(tmpdir(), 'web-cut-log-'))
const logPath = join(logDir, 'ffmpeg-args.json')
const mirror = await createMirrorMedia(WEB_SPEECH_MP3_BYTES, 'source.web.mp3')
const speech = await createWebSpeech({ mirrorMedia: mirror, platform: 'radio' })
const cutId = await createCut({ speech, startSeconds: 10, endSeconds: 40 })
const previousLog = process.env.FAKE_FFMPEG_LOG
process.env.FAKE_FFMPEG_LOG = logPath
const restoreFfmpeg = withFfmpegPath(FAKE_FFMPEG)
vi.stubGlobal('fetch', () => {
throw new Error('a web cut must not reach the Câmara')
})
try {
await runSpeechCutJob(payload, cutId)
const args = JSON.parse(readFileSync(logPath, 'utf8')) as string[]
expect(args).toContain('0:a:0')
expect(args).not.toContain('0:v:0')
const cut = await payload.findByID({
collection: 'speechCut',
id: cutId,
depth: 1,
overrideAccess: true,
})
expect(cut.status).toBe('published')
const media = typeof cut.media === 'object' && cut.media !== null ? cut.media : null
if (!media) throw new Error('media was not attached')
createdMediaIds.add(media.id)
} finally {
if (previousLog === undefined) delete process.env.FAKE_FFMPEG_LOG
else process.env.FAKE_FFMPEG_LOG = previousLog
restoreFfmpeg()
vi.unstubAllGlobals()
rmSync(logDir, { recursive: true, force: true })
}
})
it('shows the web origin (platform and detail link) on the cut', async () => {
const mirror = await createMirrorMedia(WEB_SPEECH_MP4_BYTES, 'source.web.mp4')
const speech = await createWebSpeech({
mirrorMedia: mirror,
platform: 'radio',
title: 'Entrevista na rádio Metrópole',
})
const cutId = await createCut({ speech, startSeconds: 0, endSeconds: 30 })
const actors = await createUsers()
const view = await loadSpeechCutDetailPageData(payload, actors.communicator, cutId)
expect(view.origin).toEqual({
id: speech,
source: 'web',
label: 'Entrevista na rádio Metrópole',
platform: 'radio',
href: `/campanha/comunicacao/acervo/internet/${speech}`,
})
})
})
})
describe('speech cut library (C168)', () => {
beforeAll(async () => {
payload = await getPayload({ config: await config })
})
afterEach(() => {
mockedGetContext.mockReset()
})
it('lists newest first and searches title or description case-insensitively', async () => {
const speech = await createSpeech()
const marker = `zebra${randomUUID().slice(0, 8)}`
const older = await createCut({
speech,
startSeconds: 0,
endSeconds: 20,
status: 'published',
title: `Obras paradas ${marker}`,
})
const newer = await createCut({
speech,
startSeconds: 30,
endSeconds: 50,
status: 'unpublished',
title: 'Título sem o termo',
description: `Conteúdo sobre OBRAS ${marker} e emprego`,
})
const unrelated = await createCut({
speech,
startSeconds: 60,
endSeconds: 80,
status: 'published',
title: `Saúde ${marker}`,
})
const { communicator } = await createUsers()
const data = await loadSpeechCutAcervoPageData(payload, communicator, { q: `obras ${marker}` })
expect(data.rows.map((row) => row.id)).toEqual([newer, older])
expect(data.rows.map((row) => row.id)).not.toContain(unrelated)
expect(data.totalDocs).toBe(2)
})
it('carries the origin speech and degrades when it was deleted', async () => {
const speech = await createSpeech()
const withOrigin = await createCut({ speech, startSeconds: 0, endSeconds: 20 })
const orphan = await payload.create({
collection: 'speechCut',
data: {
startSeconds: 0,
endSeconds: 10,
durationSeconds: 10,
title: `Órfão ${randomUUID().slice(0, 8)}`,
description: 'Sem fala de origem',
status: 'unpublished',
},
depth: 0,
overrideAccess: true,
})
createdCutIds.add(orphan.id)
const { communicator } = await createUsers()
const withSpeech = await loadSpeechCutDetailPageData(payload, communicator, withOrigin)
expect(withSpeech.origin?.id).toBe(speech)
expect(withSpeech.origin?.href).toBe(`/campanha/comunicacao/acervo/${speech}`)
expect(withSpeech.origin?.label).toContain('07/02/2023')
expect(withSpeech.createdAtLabel).toMatch(/^\d{2}\/\d{2}\/\d{4}$/)
const withoutSpeech = await loadSpeechCutDetailPageData(payload, communicator, orphan.id)
expect(withoutSpeech.origin).toBeNull()
})
it('edits only the cut text and reflects it in the library', async () => {
const speech = await createSpeech()
const cutId = await createCut({ speech, startSeconds: 0, endSeconds: 20 })
const { communicator } = await createUsers()
asActor(communicator)
const updated = await updateSpeechCutTextForActor({
cutId,
title: 'Título revisado',
description: 'Descrição revisada',
})
expect(updated.title).toBe('Título revisado')
const stored = await payload.findByID({
collection: 'speechCut',
id: cutId,
depth: 0,
overrideAccess: true,
})
expect(stored.title).toBe('Título revisado')
expect(stored.description).toBe('Descrição revisada')
// The speech window and the (absent) video are untouched.
expect(stored.startSeconds).toBe(0)
expect(stored.endSeconds).toBe(20)
expect(stored.media ?? null).toBeNull()
const listed = await loadSpeechCutAcervoPageData(payload, communicator, { q: 'revisado' })
expect(listed.rows.map((row) => row.id)).toContain(cutId)
})
it('denies editing and publishing to advisor/leader fail-closed', async () => {
const speech = await createSpeech()
const cutId = await createCut({ speech, startSeconds: 0, endSeconds: 20 })
const { advisor, leader } = await createUsers()
for (const denied of [advisor, leader]) {
asActor(denied)
await expect(
updateSpeechCutTextForActor({ cutId, title: 't', description: 'd' }),
).rejects.toThrow(SPEECH_CUT_FORBIDDEN_MESSAGE)
await expect(setSpeechCutPublishedForActor({ cutId, published: false })).rejects.toThrow(
SPEECH_CUT_FORBIDDEN_MESSAGE,
)
}
})
it('refuses to publish a cut without a stored file', async () => {
const speech = await createSpeech()
const cutId = await createCut({
speech,
startSeconds: 0,
endSeconds: 20,
status: 'unpublished',
})
const { communicator } = await createUsers()
asActor(communicator)
await expect(setSpeechCutPublishedForActor({ cutId, published: true })).rejects.toThrow(
SPEECH_CUT_PUBLISH_NOT_READY_MESSAGE,
)
})
it('kills and restores the same public link through the toggle', async () => {
const speech = await createSpeech()
const cutId = await createCut({ speech, startSeconds: 43, endSeconds: 118 })
const { communicator } = await createUsers()
asActor(communicator)
vi.stubGlobal('fetch', camaraFetchStub(MP4_BYTES))
const restoreFfmpeg = withFfmpegPath(FAKE_FFMPEG)
try {
await runSpeechCutJob(payload, cutId)
const publishedCut = await payload.findByID({
collection: 'speechCut',
id: cutId,
depth: 1,
overrideAccess: true,
})
const media =
typeof publishedCut.media === 'object' && publishedCut.media !== null
? publishedCut.media
: null
if (media) createdMediaIds.add(media.id)
expect(publishedCut.status).toBe('published')
expect((await findCutsAs(undefined, [cutId])).docs.map((doc) => doc.id)).toEqual([cutId])
const unpublished = await setSpeechCutPublishedForActor({ cutId, published: false })
expect(unpublished.status).toBe('unpublished')
expect(unpublished.publicPath).toBe(`/corte/${cutId}`)
expect((await findCutsAs(undefined, [cutId])).docs).toHaveLength(0)
const republished = await setSpeechCutPublishedForActor({ cutId, published: true })
expect(republished.status).toBe('published')
expect(republished.publicPath).toBe(`/corte/${cutId}`)
expect((await findCutsAs(undefined, [cutId])).docs.map((doc) => doc.id)).toEqual([cutId])
} finally {
restoreFfmpeg()
vi.unstubAllGlobals()
}
})
it('throws the named not-found error for a missing cut', async () => {
const { communicator } = await createUsers()
await expect(
loadSpeechCutDetailPageData(payload, communicator, 999_999_999),
).rejects.toBeInstanceOf(SpeechCutNotFoundError)
})
it('deletes the row and removes it from the library (C183)', async () => {
const speech = await createSpeech()
const marker = `apagavel${randomUUID().slice(0, 8)}`
const cutId = await createCut({
speech,
startSeconds: 0,
endSeconds: 20,
title: `Corte ${marker}`,
})
const { communicator } = await createUsers()
asActor(communicator)
await expect(deleteSpeechCutForActor({ cutId })).resolves.toEqual({ deleted: true })
expect(await findCutById(cutId)).toBeNull()
const listed = await loadSpeechCutAcervoPageData(payload, communicator, { q: marker })
expect(listed.rows.map((row) => row.id)).not.toContain(cutId)
})
it('denies delete to advisor/leader and keeps it for the Payload admin (C183)', async () => {
const speech = await createSpeech()
const { advisor, leader } = await createUsers()
const admin = await campaignFixtures().createAdminUser()
for (const denied of [advisor, leader]) {
const cutId = await createCut({ speech, startSeconds: 0, endSeconds: 20 })
await expect(
payload.delete({
collection: 'speechCut',
id: cutId,
user: denied,
overrideAccess: false,
}),
).rejects.toThrow()
expect(await findCutById(cutId)).not.toBeNull()
asActor(denied)
await expect(deleteSpeechCutForActor({ cutId })).rejects.toThrow(SPEECH_CUT_FORBIDDEN_MESSAGE)
}
const adminCut = await createCut({ speech, startSeconds: 30, endSeconds: 50 })
await expect(
payload.delete({
collection: 'speechCut',
id: adminCut,
user: admin,
overrideAccess: false,
}),
).resolves.toBeDefined()
expect(await findCutById(adminCut)).toBeNull()
})
it('retries a failed cut in place and refuses the others (C183)', async () => {
const speech = await createSpeech()
const failed = await createCut({ speech, startSeconds: 0, endSeconds: 20, status: 'failed' })
const published = await createCut({
speech,
startSeconds: 30,
endSeconds: 50,
status: 'published',
})
const { communicator, advisor } = await createUsers()
asActor(communicator)
mockedStartJob.mockClear()
const retried = await retrySpeechCutForActor({ cutId: failed })
expect(retried.id).toBe(failed)
expect(retried.status).toBe('processing')
expect(mockedStartJob).toHaveBeenCalledWith(failed)
await expect(retrySpeechCutForActor({ cutId: published })).rejects.toThrow(
SPEECH_CUT_RETRY_NOT_FAILED_MESSAGE,
)
asActor(advisor)
await expect(retrySpeechCutForActor({ cutId: failed })).rejects.toThrow(
SPEECH_CUT_FORBIDDEN_MESSAGE,
)
})
it('leaves the stored media behind when the cut is deleted (C183 documented orphan)', async () => {
const speech = await createSpeech()
const cutId = await createCut({ speech, startSeconds: 43, endSeconds: 118 })
const { communicator } = await createUsers()
asActor(communicator)
vi.stubGlobal('fetch', camaraFetchStub(MP4_BYTES))
const restoreFfmpeg = withFfmpegPath(FAKE_FFMPEG)
try {
await runSpeechCutJob(payload, cutId)
const { media } = await expectPublishedWithFakeFfmpeg(cutId)
await deleteSpeechCutForActor({ cutId })
expect(await findCutById(cutId)).toBeNull()
const mediaRow = await payload
.findByID({ collection: 'media', id: media.id, depth: 0, overrideAccess: true })
.catch(() => null)
expect(mediaRow).not.toBeNull()
} finally {
restoreFfmpeg()
vi.unstubAllGlobals()
}
})
})
describe('speech cut discovery (C174)', () => {
beforeAll(async () => {
payload = await getPayload({ config: await config })
})
it('lists every cut of one speech and groups the batch by speech', async () => {
const speech = await createSpeech()
const otherSpeech = await createSpeech()
const older = await createCut({
speech,
startSeconds: 0,
endSeconds: 20,
title: 'Primeiro corte',
})
const newer = await createCut({
speech,
startSeconds: 30,
endSeconds: 50,
title: 'Segundo corte',
})
const unrelated = await createCut({
speech: otherSpeech,
startSeconds: 0,
endSeconds: 20,
title: 'Corte de outra fala',
})
const { communicator } = await createUsers()
const forSpeech = await loadSpeechCutsForSpeech(payload, communicator, speech)
expect(forSpeech.map((cut) => cut.id)).toEqual([newer, older])
expect(forSpeech.map((cut) => cut.id)).not.toContain(unrelated)
// C180 — the summary view carries only what the section renders (no media/file internals).
expect(Object.keys(forSpeech[0] ?? {}).sort()).toEqual([
'description',
'durationSeconds',
'id',
'status',
'title',
])
expect(forSpeech[0]?.durationSeconds).toBe(20)
const grouped = await loadSpeechCutsForSpeeches(payload, communicator, [speech, otherSpeech])
expect(grouped.get(speech)?.map((cut) => cut.id)).toEqual([newer, older])
expect(grouped.get(otherSpeech)?.map((cut) => cut.id)).toEqual([unrelated])
expect((await loadSpeechCutsForSpeeches(payload, communicator, [])).size).toBe(0)
})
it('keeps the collection access on the section loader (advisor sees published only)', async () => {
const speech = await createSpeech()
await createCut({ speech, startSeconds: 0, endSeconds: 20, status: 'published' })
await createCut({ speech, startSeconds: 30, endSeconds: 50, status: 'unpublished' })
const { communicator, advisor } = await createUsers()
expect(await loadSpeechCutsForSpeech(payload, communicator, speech)).toHaveLength(2)
expect(await loadSpeechCutsForSpeech(payload, advisor, speech)).toHaveLength(1)
})
it('surfaces an origin speech through a matching cut, flagged as origin-only', async () => {
const marker = `reformatrib${randomUUID().slice(0, 8)}`
const speech = await createSpeech()
const cutId = await createCut({
speech,
startSeconds: 0,
endSeconds: 20,
status: 'published',
title: `Reforma ${marker}`,
})
const { communicator } = await createUsers()
expect(await loadSpeechCutOriginSpeechIds(payload, communicator, marker)).toEqual([speech])
const data = await loadSpeechAcervoPageData(payload, communicator, { q: marker })
const row = data.rows.find((candidate) => candidate.id === speech)
expect(row).toBeDefined()
expect(row?.matchedTextSearch).toBe(false)
expect(row?.cuts.map((cut) => cut.id)).toEqual([cutId])
})
it('flags a speech whose own text matches as a regular result', async () => {
const marker = `saudepublica${randomUUID().slice(0, 8)}`
await createSpeech({
segments: [{ startSeconds: 0, endSeconds: 2.7, text: `A saúde pública ${marker}` }],
})
const { communicator } = await createUsers()
const data = await loadSpeechAcervoPageData(payload, communicator, { q: marker })
const row = data.rows.find((candidate) => candidate.matchedTextSearch)
expect(row).toBeDefined()
expect(row?.cuts).toHaveLength(0)
})
it('flags a speech whose keyword matches case-insensitively as a regular result', async () => {
// C180 — the SQL `contains` is ILIKE; the view-model mirror follows the same
// case-insensitive semantics instead of showing the origin-only note wrongly.
const marker = `saudepublica${randomUUID().slice(0, 8)}`
const speech = await createSpeech({
keywords: [`SAÚDE ${marker.toUpperCase()}`],
segments: [{ startSeconds: 0, endSeconds: 2.7, text: `Discurso ${randomUUID()}` }],
})
const { communicator } = await createUsers()
const data = await loadSpeechAcervoPageData(payload, communicator, { q: `saúde ${marker}` })
const row = data.rows.find((candidate) => candidate.id === speech)
expect(row?.matchedTextSearch).toBe(true)
})
})