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

558 lines
18 KiB
TypeScript

// @vitest-environment node
import { fileURLToPath } from 'node:url'
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: () => undefined,
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/acervo/gravacoes/[id]/arquivo/route'
import {
deleteRecordingForActor,
getRecordingStatusesForActor,
retryRecordingForActor,
} from '@/app/(campaign)/campanha/actions/recording'
import { RECORDING_MAX_BYTES } from '@/lib/recording'
import { normalizeForSearch } from '@/lib/speechSearch'
import type { CampaignUser, RecordingMedia } from '@/payload-types'
import config from '@/payload.config'
import { hookFilledCreateData } from '@/utilities/hookFilledData'
import {
reapStaleRecording,
RECORDING_STALE_MS,
runRecordingJob,
} from '@/utilities/recordings/recordingJob'
import { loadRecordingsPageData } from '@/utilities/recordings/recordingPageData'
import { receiveRecordingUpload } from '@/utilities/recordings/recordingUpload'
import { installCampaignFixtures } from '../helpers/campaignFixtures'
const FAKE_FFMPEG = fileURLToPath(new URL('../fixtures/fake-ffmpeg.mjs', import.meta.url))
const VIDEO_BYTES = Buffer.from('fixture-recording-bytes')
let payload: Payload
const createdRecordingIds = 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
}
}
const createMedia = async (
name: string,
data: Buffer = VIDEO_BYTES,
mimetype = 'video/mp4',
): Promise<RecordingMedia> => {
const media = await payload.create({
collection: 'recordingMedia',
data: { alt: `Arquivo ${name}` },
file: { data, mimetype, name, size: data.length },
overrideAccess: true,
})
createdMediaIds.add(media.id)
return media
}
const createRecording = async ({
status = 'ready',
title = 'Plenária da Comissão',
segments = [],
withMedia = true,
}: {
status?: 'uploading' | 'processing' | 'ready' | 'failed'
title?: string
segments?: { startSeconds: number; endSeconds: number; text: string }[]
withMedia?: boolean
} = {}) => {
const media = withMedia ? await createMedia(`${title}.mp4`) : null
const recording = await payload.create({
collection: 'recording',
data: {
title,
status,
...(media ? { media: media.id } : {}),
},
overrideAccess: true,
})
createdRecordingIds.add(recording.id)
for (const [index, segment] of segments.entries()) {
await payload.create({
collection: 'recordingSegment',
data: hookFilledCreateData<'recordingSegment'>({
recording: recording.id,
order: index + 1,
startSeconds: segment.startSeconds,
endSeconds: segment.endSeconds,
text: segment.text,
}),
overrideAccess: true,
})
}
if (segments.length > 0) {
await payload.update({
collection: 'recording',
id: recording.id,
data: { searchText: normalizeForSearch(segments.map((segment) => segment.text).join(' ')) },
overrideAccess: true,
})
}
return { recording, media }
}
const callFileRoute = async ({
recordingId,
user,
range,
download,
}: {
recordingId: number
user?: CampaignUser | null
range?: string
download?: boolean
}): Promise<Response> => {
getCampaignUserMock.mockResolvedValue(user ?? null)
const request = new Request(
`http://localhost/campanha/comunicacao/acervo/gravacoes/${recordingId}/arquivo${download ? '?download=1' : ''}`,
{ headers: range ? { range } : undefined },
)
return GET(request, { params: Promise.resolve({ id: String(recordingId) }) })
}
describe('uploaded recordings (C199)', () => {
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 createdRecordingIds) {
await payload
.delete({ collection: 'recording', id, overrideAccess: true })
.catch(() => undefined)
}
for (const id of createdMediaIds) {
await payload
.delete({ collection: 'recordingMedia', id, overrideAccess: true })
.catch(() => undefined)
}
})
it('reads recordings only with the communication roles', async () => {
const { recording } = await createRecording()
const readAs = (user?: CampaignUser) =>
payload.find({
collection: 'recording',
where: { id: { equals: recording.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('stamps the actor on the create path', async () => {
const recording = await payload.create({
collection: 'recording',
data: { title: 'Plenária com carimbo', status: 'uploading' },
depth: 0,
user: communicator,
overrideAccess: false,
})
createdRecordingIds.add(recording.id)
expect(recording.createdBy).toBe(communicator.id)
})
it('serves the file once the upload finished and hides it while uploading', async () => {
const ready = await createRecording({ status: 'ready' })
const processing = await createRecording({ status: 'processing' })
const failed = await createRecording({ status: 'failed' })
const uploading = await createRecording({ status: 'uploading', withMedia: false })
for (const recording of [ready.recording, processing.recording, failed.recording]) {
const response = await callFileRoute({ recordingId: recording.id, user: communicator })
expect(response.status).toBe(200)
expect(response.headers.get('Content-Type')).toBe('video/mp4')
expect(response.headers.get('Cache-Control')).toBe('private, no-store')
expect(Buffer.from(await response.arrayBuffer())).toEqual(VIDEO_BYTES)
}
expect(
(await callFileRoute({ recordingId: uploading.recording.id, user: communicator })).status,
).toBe(404)
})
it('answers a byte range with 206 and forces download with ?download=1', async () => {
const { recording } = await createRecording()
const partial = await callFileRoute({
recordingId: recording.id,
user: coordinator,
range: 'bytes=0-3',
})
expect(partial.status).toBe(206)
expect(partial.headers.get('Content-Range')).toBe(`bytes 0-3/${VIDEO_BYTES.length}`)
expect(Buffer.from(await partial.arrayBuffer())).toEqual(VIDEO_BYTES.subarray(0, 4))
const download = await callFileRoute({
recordingId: recording.id,
user: coordinator,
download: true,
})
expect(download.headers.get('Content-Disposition')).toContain('attachment')
})
it('denies anonymous, advisor and leader with a silent 404', async () => {
const { recording } = await createRecording()
for (const user of [undefined, advisor, leader]) {
expect((await callFileRoute({ recordingId: recording.id, user })).status).toBe(404)
}
})
it('deletes the transcript in cascade with the recording', async () => {
const { recording } = await createRecording({
segments: [{ startSeconds: 0, endSeconds: 4, text: 'Primeiro trecho' }],
})
await payload.delete({ collection: 'recording', id: recording.id, overrideAccess: true })
createdRecordingIds.delete(recording.id)
const segments = await payload.find({
collection: 'recordingSegment',
where: { recording: { equals: recording.id } },
depth: 0,
limit: 0,
pagination: false,
overrideAccess: true,
})
expect(segments.docs).toHaveLength(0)
})
it('streams an upload to the private media and hands the row to the job', async () => {
const bytes = Buffer.from('raw-body-upload')
const body = new ReadableStream<Uint8Array>({
start(controller) {
controller.enqueue(bytes)
controller.close()
},
})
const scheduled: number[] = []
const { id } = await receiveRecordingUpload({
payload,
actor: communicator,
metadata: {
title: 'Giro pelo interior',
filename: 'giro interior.m4v',
recordedAt: undefined,
},
body,
contentLength: bytes.length,
startJob: (recordingId) => scheduled.push(recordingId),
})
createdRecordingIds.add(id)
const recording = await payload.findByID({
collection: 'recording',
id,
depth: 1,
overrideAccess: true,
})
expect(recording.status).toBe('processing')
expect(recording.step).toBe('extracting')
const createdBy =
typeof recording.createdBy === 'number' ? recording.createdBy : recording.createdBy?.id
expect(createdBy).toBe(communicator.id)
expect(scheduled).toEqual([id])
const media = recording.media
expect(media && typeof media === 'object').toBe(true)
if (media && typeof media === 'object') {
createdMediaIds.add(media.id)
expect(media.filename).toContain('giro_interior')
}
const response = await callFileRoute({ recordingId: id, user: communicator })
expect(response.status).toBe(200)
expect(Buffer.from(await response.arrayBuffer())).toEqual(bytes)
})
it('refuses an upload above the ceiling without leaving a phantom row', async () => {
const body = new ReadableStream<Uint8Array>({
start(controller) {
controller.enqueue(Buffer.alloc(8))
controller.close()
},
})
await expect(
receiveRecordingUpload({
payload,
actor: communicator,
metadata: { title: 'Grande demais', filename: 'grande.mp4', recordedAt: undefined },
body,
contentLength: RECORDING_MAX_BYTES + 1,
startJob: () => undefined,
}),
).rejects.toThrow('O arquivo excede o limite de 4 GB.')
const rows = await payload.find({
collection: 'recording',
where: { title: { equals: 'Grande demais' } },
overrideAccess: true,
})
expect(rows.docs).toHaveLength(0)
})
it('runs the transcription job end to end with merged timestamps', async () => {
const { recording } = await createRecording({
status: 'processing',
title: 'Plenária de horas',
})
const restore = withEnv('FFMPEG_PATH', FAKE_FFMPEG)
try {
await runRecordingJob(payload, recording.id, async () => ({
ok: true,
segments: [
{ start: 1, end: 4, text: 'Bom dia a todas' },
{ start: 5, end: 9, text: 'e a todos' },
],
durationSeconds: 1_200,
}))
} finally {
restore()
}
const updated = await payload.findByID({
collection: 'recording',
id: recording.id,
depth: 0,
overrideAccess: true,
})
expect(updated.status).toBe('ready')
expect(updated.step).toBeNull()
expect(updated.error).toBeNull()
// The fake ffmpeg writes two chunks and the injected transcriber answers the
// same segments for both, so the transcript carries them twice (chunk 2 at
// the 20-minute offset).
expect(updated.durationSeconds).toBe(2_400)
expect(updated.searchText).toBe('bom dia a todas e a todos bom dia a todas e a todos')
const segments = await payload.find({
collection: 'recordingSegment',
where: { recording: { equals: recording.id } },
depth: 0,
limit: 0,
pagination: false,
sort: 'order',
overrideAccess: true,
})
expect(segments.docs).toHaveLength(4)
expect(segments.docs.map((segment) => segment.startSeconds)).toEqual([1, 5, 1201, 1205])
expect(segments.docs[0]?.searchText).toBe('bom dia a todas')
})
it('marks a provider failure as failed, preserves the file and retries', async () => {
const { recording } = await createRecording({ status: 'processing', title: 'Debate' })
const restore = withEnv('FFMPEG_PATH', FAKE_FFMPEG)
try {
await runRecordingJob(payload, recording.id, async () => ({
ok: false,
error: 'Não foi possível transcrever a gravação.',
status: 502,
}))
} finally {
restore()
}
const failed = await payload.findByID({
collection: 'recording',
id: recording.id,
depth: 1,
overrideAccess: true,
})
expect(failed.status).toBe('failed')
expect(failed.step).toBe('transcribing')
expect(failed.error).toBe('Não foi possível transcrever a gravação.')
expect(failed.media && typeof failed.media === 'object').toBe(true)
getCampaignUserMock.mockResolvedValue(communicator)
const retried = await retryRecordingForActor({ recordingId: recording.id })
expect(retried.status).toBe('processing')
await expect(
retryRecordingForActor({ recordingId: (await createRecording()).recording.id }),
).rejects.toThrow('Só é possível reprocessar uma gravação que falhou.')
})
it('reaps a processing row abandoned by a restart', async () => {
const { recording } = await createRecording({ status: 'processing', title: 'Abandonada' })
const reaped = await reapStaleRecording(payload, {
id: recording.id,
status: 'processing',
updatedAt: new Date(Date.now() - RECORDING_STALE_MS - 60_000).toISOString(),
})
expect(reaped).toBe(true)
const updated = await payload.findByID({
collection: 'recording',
id: recording.id,
depth: 0,
overrideAccess: true,
})
expect(updated.status).toBe('failed')
expect(updated.error).toBe('O processamento foi interrompido antes de terminar.')
// A fresh row is never reaped.
const fresh = await createRecording({ status: 'processing' })
expect(
await reapStaleRecording(payload, {
id: fresh.recording.id,
status: 'processing',
updatedAt: new Date().toISOString(),
}),
).toBe(false)
// An `uploading` row abandoned mid-stream has no usable file: reaped away.
const orphan = await createRecording({
status: 'uploading',
title: 'Upload interrompido',
withMedia: false,
})
expect(
await reapStaleRecording(payload, {
id: orphan.recording.id,
status: 'uploading',
updatedAt: new Date(Date.now() - RECORDING_STALE_MS - 60_000).toISOString(),
}),
).toBe(true)
createdRecordingIds.delete(orphan.recording.id)
await expect(
payload.findByID({ collection: 'recording', id: orphan.recording.id, overrideAccess: true }),
).rejects.toThrow()
})
it('polls the visible statuses and deletes a recording with its media', async () => {
const { recording, media } = await createRecording({ status: 'failed', title: 'Para apagar' })
getCampaignUserMock.mockResolvedValue(communicator)
const statuses = await getRecordingStatusesForActor({ recordingIds: [recording.id] })
expect(statuses).toHaveLength(1)
expect(statuses[0]).toMatchObject({ id: recording.id, status: 'failed' })
await deleteRecordingForActor({ recordingId: recording.id })
createdRecordingIds.delete(recording.id)
await expect(
payload.findByID({ collection: 'recording', id: recording.id, overrideAccess: true }),
).rejects.toThrow()
if (media) {
// The media cleanup runs after commit, best-effort; poll for it.
let deleted = false
for (let attempt = 0; attempt < 20 && !deleted; attempt += 1) {
deleted = await payload
.findByID({ collection: 'recordingMedia', id: media.id, overrideAccess: true })
.then(() => false)
.catch(() => true)
if (!deleted) await new Promise((resolve) => setTimeout(resolve, 50))
}
expect(deleted).toBe(true)
createdMediaIds.delete(media.id)
}
})
it('finds the spoken excerpt of any person through the recordings search', async () => {
// A unique term keeps the assertion independent from residue left in the
// shared test database by earlier runs.
const marker = `merenda${Date.now().toString(36)}`
const { recording } = await createRecording({
title: `Comissão de Educação ${marker}`,
segments: [
{ startSeconds: 0, endSeconds: 6, text: `A deputada falou sobre a ${marker} escolar.` },
{ startSeconds: 6, endSeconds: 12, text: 'O relator respondeu em seguida.' },
],
})
await createRecording({ title: 'Outra gravação', segments: [] })
const data = await loadRecordingsPageData(payload, communicator, {
source: 'enviadas',
q: marker,
})
expect(data.rows).toHaveLength(1)
expect(data.rows[0]?.id).toBe(recording.id)
expect(data.rows[0]?.excerpt?.parts.map((part) => part.text).join('')).toContain(marker)
expect(data.rows[0]?.watchHref).toContain('t=0')
expect(data.rows[0]?.watchHref).toContain(`q=${marker}`)
const unfiltered = await loadRecordingsPageData(payload, communicator, {
source: 'enviadas',
})
expect(unfiltered.rows.length).toBeGreaterThanOrEqual(2)
expect(unfiltered.rows.every((row) => row.excerpt === null)).toBe(true)
})
})