mirror of
https://github.com/fsolla/teqo.git
synced 2026-11-12 22:13:05 +00:00
O diálogo "Enviar gravação" mandava o arquivo inteiro num único XHR e a produção vive atrás do Cloudflare Tunnel, cujo edge responde 413 para qualquer request body acima de ~100 MB ANTES do app ver o corpo (probe read-only: POST de 150 MB cortado após ~3,2 MB). Um .mp4 de 6 GB de plenária nunca chegava à rota — o C199-large havia removido só o teto do app, não o do proxy. O upload agora é fatiado ponta a ponta: o start bodyless cria a row e a sessão em disco (`recording-upload-<id>` com estado + arquivo anexado), o diálogo envia partes de 16 MiB em ordem estrita com progresso agregado e o último byte do tamanho declarado finaliza a mídia privada e o job. Cada parte faz heartbeat do `updatedAt` contra o reaper de 1 h, o start faz preflight de disco (statfs + margem), falha de parte aborta a sessão e o delete do usuário/reaper limpam o diretório. Sem migration e sem schema. Regressão: protocolo int nas rotas start/chunk/abort (multi-parte, fora de ordem, tamanho excedido, seam multipart, falha S3, reaper), guarda unit do orçamento do edge, guardas e2e das rotas. Post-mortem e entrada de changelog em docs/.
213 lines
8 KiB
TypeScript
213 lines
8 KiB
TypeScript
// @vitest-environment node
|
|
|
|
import { describe, expect, it } from 'vitest'
|
|
|
|
import {
|
|
canRetryRecording,
|
|
canServeRecordingMedia,
|
|
formatRecordingFileSize,
|
|
isRecordingStatus,
|
|
RECORDING_FAILURE_INTERRUPTED,
|
|
RECORDING_STATUSES,
|
|
RECORDING_STEPS,
|
|
RECORDING_UPLOAD_CHUNK_BYTES,
|
|
RECORDING_UPLOAD_EDGE_LIMIT_BYTES,
|
|
recordingFailureMessage,
|
|
recordingFileTypeAllowed,
|
|
recordingStatusLabels,
|
|
recordingUploadFitsInDisk,
|
|
toRecordingViewModel,
|
|
} from '@/lib/recording'
|
|
import {
|
|
buildRecordingAudioFfmpegArgs,
|
|
buildRecordingFullAudioFfmpegArgs,
|
|
mergeChunkTranscriptions,
|
|
RECORDING_AUDIO_CHUNK_SECONDS,
|
|
recordingSearchText,
|
|
} from '@/lib/recordingTranscription'
|
|
|
|
describe('recording vocabulary (C199)', () => {
|
|
it('pins the statuses, steps and their labels', () => {
|
|
expect(RECORDING_STATUSES).toEqual(['uploading', 'processing', 'ready', 'failed'])
|
|
expect(Object.keys(recordingStatusLabels).sort()).toEqual([...RECORDING_STATUSES].sort())
|
|
expect(RECORDING_STEPS).toEqual(['extracting', 'transcribing', 'saving'])
|
|
expect(isRecordingStatus('failed')).toBe(true)
|
|
expect(isRecordingStatus('published')).toBe(false)
|
|
})
|
|
|
|
it('serves media from processing on, never during the upload stream', () => {
|
|
expect(canServeRecordingMedia('uploading')).toBe(false)
|
|
expect(canServeRecordingMedia('processing')).toBe(true)
|
|
expect(canServeRecordingMedia('failed')).toBe(true)
|
|
expect(canServeRecordingMedia('ready')).toBe(true)
|
|
})
|
|
|
|
it('only offers retry for a failed transcription', () => {
|
|
expect(canRetryRecording('failed')).toBe(true)
|
|
expect(canRetryRecording('ready')).toBe(false)
|
|
expect(canRetryRecording('processing')).toBe(false)
|
|
})
|
|
|
|
it('maps the failure cause to honest copy', () => {
|
|
expect(
|
|
recordingFailureMessage({ error: RECORDING_FAILURE_INTERRUPTED, step: 'transcribing' }),
|
|
).toBe(RECORDING_FAILURE_INTERRUPTED)
|
|
expect(recordingFailureMessage({ error: 'raw stderr detail', step: 'extracting' })).toBe(
|
|
'Não foi possível preparar o áudio da gravação.',
|
|
)
|
|
expect(recordingFailureMessage({ error: 'raw stderr detail', step: 'transcribing' })).toBe(
|
|
'Não foi possível transcrever a gravação.',
|
|
)
|
|
expect(recordingFailureMessage({ error: 'raw stderr detail', step: 'saving' })).toBe(
|
|
'Não foi possível guardar a transcrição.',
|
|
)
|
|
expect(recordingFailureMessage({ error: 'raw detail', step: null })).toBe(
|
|
'Não foi possível transcrever a gravação.',
|
|
)
|
|
expect(recordingFailureMessage({ error: null, step: 'transcribing' })).toBeNull()
|
|
expect(recordingFailureMessage({ error: ' ', step: 'transcribing' })).toBeNull()
|
|
})
|
|
|
|
it('accepts videos and unknown types, refuses every other type', () => {
|
|
expect(recordingFileTypeAllowed('video/mp4')).toBe(true)
|
|
expect(recordingFileTypeAllowed('video/quicktime')).toBe(true)
|
|
expect(recordingFileTypeAllowed('')).toBe(true)
|
|
expect(recordingFileTypeAllowed(null)).toBe(true)
|
|
expect(recordingFileTypeAllowed('text/plain')).toBe(false)
|
|
expect(recordingFileTypeAllowed('image/png')).toBe(false)
|
|
})
|
|
|
|
it('formats sizes in pt-BR without imposing a ceiling', () => {
|
|
expect(formatRecordingFileSize(4.6 * 1024 ** 3)).toBe('4,6 GB')
|
|
expect(formatRecordingFileSize(24 * 1024 ** 3)).toBe('24 GB')
|
|
})
|
|
|
|
it('keeps every upload request under the production edge ceiling', () => {
|
|
// Production is behind the Cloudflare Tunnel: the edge answers `413 Payload
|
|
// Too Large` for a request body above ~100 MB BEFORE the app sees it (probed
|
|
// against prod, a 150 MB body answered 413). The dialog slices the file into
|
|
// parts of `RECORDING_UPLOAD_CHUNK_BYTES`, so raising it past the edge limit
|
|
// would silently break every multi-GB recording upload again.
|
|
expect(RECORDING_UPLOAD_CHUNK_BYTES).toBeGreaterThanOrEqual(1024 * 1024)
|
|
expect(RECORDING_UPLOAD_CHUNK_BYTES).toBeLessThan(RECORDING_UPLOAD_EDGE_LIMIT_BYTES)
|
|
})
|
|
|
|
it('demands room for the declared file plus a margin before the upload starts', () => {
|
|
const sixGiB = 6 * 1024 ** 3
|
|
expect(recordingUploadFitsInDisk({ freeBytes: 20 * 1024 ** 3, expectedBytes: sixGiB })).toBe(
|
|
true,
|
|
)
|
|
expect(
|
|
recordingUploadFitsInDisk({ freeBytes: sixGiB + 100 * 1024 ** 2, expectedBytes: sixGiB }),
|
|
).toBe(false)
|
|
expect(recordingUploadFitsInDisk({ freeBytes: Number.NaN, expectedBytes: 0 })).toBe(false)
|
|
})
|
|
|
|
it('formats the byte range below 1 KB honestly', () => {
|
|
expect(formatRecordingFileSize(0)).toBe('0 B')
|
|
expect(formatRecordingFileSize(900)).toBe('900 B')
|
|
expect(formatRecordingFileSize(2048)).toBe('2 KB')
|
|
})
|
|
|
|
it('builds the view model with labels and hrefs', () => {
|
|
const view = toRecordingViewModel({
|
|
id: 7,
|
|
title: ' Plenária da Comissão ',
|
|
status: 'ready',
|
|
step: null,
|
|
recordedAt: '2026-09-01T00:00:00.000Z',
|
|
durationSeconds: 3725,
|
|
})
|
|
expect(view.title).toBe('Plenária da Comissão')
|
|
expect(view.recordedAtLabel).toBe('01/09/2026')
|
|
expect(view.durationLabel).toBe('1:02:05')
|
|
expect(view.detailHref).toBe('/campanha/comunicacao/acervo/gravacoes/7')
|
|
expect(view.fileHref).toBe('/campanha/comunicacao/acervo/gravacoes/7/arquivo')
|
|
expect(view.downloadHref).toBe('/campanha/comunicacao/acervo/gravacoes/7/arquivo?download=1')
|
|
expect(view.retryHref).toBe('/campanha/comunicacao/acervo/gravacoes/7/retry')
|
|
expect(view.deleteHref).toBe('/campanha/comunicacao/acervo/gravacoes/7/apagar')
|
|
|
|
const degraded = toRecordingViewModel({
|
|
id: 8,
|
|
title: '',
|
|
status: 'stray',
|
|
step: 'stray',
|
|
recordedAt: null,
|
|
durationSeconds: null,
|
|
})
|
|
expect(degraded.title).toBe('Gravação 8')
|
|
expect(degraded.status).toBe('failed')
|
|
expect(degraded.step).toBeNull()
|
|
expect(degraded.recordedAtLabel).toBeNull()
|
|
expect(degraded.durationLabel).toBeNull()
|
|
})
|
|
})
|
|
|
|
describe('recording transcription (C199)', () => {
|
|
it('builds the extract-and-split ffmpeg command', () => {
|
|
const args = buildRecordingAudioFfmpegArgs({
|
|
inputPath: '/tmp/in.mkv',
|
|
outputPattern: '/tmp/chunk-%03d.mp3',
|
|
})
|
|
expect(args).toContain('-vn')
|
|
expect(args).toContain('libmp3lame')
|
|
expect(args[args.indexOf('-segment_time') + 1]).toBe(String(RECORDING_AUDIO_CHUNK_SECONDS))
|
|
expect(args.at(-1)).toBe('/tmp/chunk-%03d.mp3')
|
|
})
|
|
|
|
it('merges chunks with absolute offsets, contiguous order and clamped spans', () => {
|
|
const merged = mergeChunkTranscriptions([
|
|
{
|
|
offsetSeconds: 0,
|
|
segments: [
|
|
{ start: 1, end: 4, text: 'primeiro trecho' },
|
|
{ start: 4, end: 2, text: 'clamp aqui' },
|
|
{ start: 5, end: 6, text: ' ' },
|
|
],
|
|
},
|
|
{
|
|
offsetSeconds: 1200,
|
|
segments: [{ start: 0.5, end: 3, text: 'segundo chunk' }],
|
|
},
|
|
])
|
|
|
|
expect(merged).toHaveLength(3)
|
|
expect(merged[0]).toEqual({
|
|
order: 0,
|
|
startSeconds: 1,
|
|
endSeconds: 4,
|
|
text: 'primeiro trecho',
|
|
})
|
|
expect(merged[1]).toEqual({ order: 1, startSeconds: 4, endSeconds: 4, text: 'clamp aqui' })
|
|
expect(merged[2]).toMatchObject({
|
|
order: 2,
|
|
startSeconds: 1200.5,
|
|
endSeconds: 1203,
|
|
text: 'segundo chunk',
|
|
})
|
|
})
|
|
|
|
it('normalizes the concatenated search text', () => {
|
|
expect(recordingSearchText([{ text: 'Ação da Comissão' }, { text: ' de EDUCAÇÃO ' }])).toBe(
|
|
'acao da comissao de educacao',
|
|
)
|
|
})
|
|
|
|
it('returns nothing for an empty transcription', () => {
|
|
expect(mergeChunkTranscriptions([])).toEqual([])
|
|
expect(recordingSearchText([])).toBe('')
|
|
})
|
|
|
|
it('builds the single-file extraction for diarization (C200)', () => {
|
|
const args = buildRecordingFullAudioFfmpegArgs({
|
|
inputPath: '/tmp/in.mkv',
|
|
outputPath: '/tmp/full.mp3',
|
|
})
|
|
expect(args).toContain('-vn')
|
|
expect(args).toContain('libmp3lame')
|
|
// One output, no segmentation: speaker numbering needs the whole audio.
|
|
expect(args).not.toContain('-f')
|
|
expect(args).not.toContain('-segment_time')
|
|
expect(args.at(-1)).toBe('/tmp/full.mp3')
|
|
})
|
|
})
|