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

337 lines
11 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters

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

// @vitest-environment node
import { randomUUID } from 'node:crypto'
import type { Payload } from 'payload'
import { getPayload } from 'payload'
import { afterAll, beforeAll, describe, expect, it } from 'vitest'
import { matchMunicipalityMentions } from '@/lib/speechGazetteer'
import config from '@/payload.config'
import { upsertSpeechBundle, type SpeechImportBundle } from '@/utilities/speech/speechImport'
import {
loadSpeechAcervoPageData,
loadSpeechDetailPageData,
SpeechNotFoundError,
type SpeechVideoStartResolver,
} from '@/utilities/speech/speechPageData'
import { installCampaignFixtures } from '../helpers/campaignFixtures'
import { speechBundleFixture } from '../helpers/speechBundleFixture'
let payload: Payload
const createdSourceKeys = new Set<string>()
const campaignFixtures = installCampaignFixtures({
getPayload: () => payload,
setPayload: (nextPayload) => {
payload = nextPayload
},
})
const feiraDeSantana = () => matchMunicipalityMentions('Feira de Santana')
const feiraDeSantanaId = async (): Promise<number> => {
const found = await payload.find({
collection: 'municipality',
where: { slug: { equals: 'feira-de-santana' } },
depth: 0,
limit: 1,
overrideAccess: true,
})
const municipality = found.docs[0]
if (!municipality) throw new Error('seeded municipality missing')
return municipality.id
}
const baseBundle = (overrides: Partial<SpeechImportBundle> = {}): SpeechImportBundle => {
const bundle = speechBundleFixture(overrides)
createdSourceKeys.add(bundle.sourceKey)
return bundle
}
const createSpeech = async (overrides: Partial<SpeechImportBundle> = {}): Promise<number> => {
const bundle = baseBundle(overrides)
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
}
/**
* C172 — the detail loader asks YouTube for the session video's start by
* default; the fixtures keep the suite offline by injecting a resolver.
*/
const noVideoStart: SpeechVideoStartResolver = async () => null
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 }
}
describe('speech acervo (C154)', () => {
beforeAll(async () => {
payload = await getPayload({ config: await config })
})
afterAll(async () => {
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,
})
}
}
})
it('searches the segments accent-insensitively and paginates by speech', async () => {
const runId = randomUUID().slice(0, 8)
const marker = `zebra${runId}`
const first = await createSpeech({
segments: [
{ startSeconds: 0, endSeconds: 3, text: `A defesa da saúde ${marker}` },
{ startSeconds: 3, endSeconds: 6, text: `Mais saúde ${marker} para a Bahia` },
],
})
const second = await createSpeech({
segments: [{ startSeconds: 0, endSeconds: 3, text: `Saúde ${marker} no interior` }],
})
const { coordinator } = await createUsers()
const data = await loadSpeechAcervoPageData(payload, coordinator, {
q: `saude ${marker}`,
})
// Three matching segments, two speeches: the count is per speech.
expect(data.totalDocs).toBe(2)
expect(data.rows.map((row) => row.id).sort()).toEqual([first, second].sort())
expect(data.rows.every((row) => row.matchKind === 'segment')).toBe(true)
const excerptText = data.rows
.flatMap((row) => row.excerpt.parts)
.map((part) => part.text)
.join('')
expect(excerptText).toContain(marker)
})
it('matches an official keyword as an exact term', async () => {
const runId = randomUUID().slice(0, 8)
const id = await createSpeech({
keywords: [`Farmácia Popular ${runId}`],
segments: [{ startSeconds: 0, endSeconds: 2, text: 'Discurso sem o termo buscado' }],
})
const { communicator } = await createUsers()
const data = await loadSpeechAcervoPageData(payload, communicator, {
q: `Farmácia Popular ${runId}`,
})
const row = data.rows.find((item) => item.id === id)
expect(row?.matchKind).toBe('keyword')
})
it('filters by topic, year, duration and cited municipality', async () => {
const id = await createSpeech({
speechAt: '2019-05-10T15:00',
year: 2019,
durationSeconds: 90,
facets: {
topics: ['igualdade-racial'],
scopes: ['brasil'],
municipalities: feiraDeSantana(),
people: [],
programs: [],
projects: [],
classifiedBy: 'gazetteer',
},
})
const { communicator } = await createUsers()
const municipalityId = await feiraDeSantanaId()
const data = await loadSpeechAcervoPageData(payload, communicator, {
topic: 'igualdade-racial',
year: '2019',
duration: 'curta',
municipality: String(municipalityId),
})
expect(data.rows.map((row) => row.id)).toContain(id)
expect(data.filterOptions.years).toContain(2019)
})
it('returns the detail with segments, cited municipalities and the seek offset', async () => {
const id = await createSpeech({
segments: [
{ startSeconds: 0, endSeconds: 3, text: 'Primeiro trecho' },
{ startSeconds: 3, endSeconds: 8, text: 'O trecho sobre a saúde' },
],
})
const { communicator } = await createUsers()
const view = await loadSpeechDetailPageData(payload, communicator, id, undefined, noVideoStart)
expect(view.id).toBe(id)
expect(view.segments).toHaveLength(2)
expect(view.segments[1]?.startSeconds).toBe(3)
// C166 — the excerpt picker needs the raw duration and the day-only label.
expect(view.durationSeconds).toBe(252)
expect(view.speechDateLabel).toBe('07/02/2023')
expect(view.municipalities.map((municipality) => municipality.name)).toContain(
'Feira de Santana',
)
expect(view.presidingOfficer).toBe('Pompeo de Mattos')
})
it('derives the YouTube default and the resoluble VOD from the detail row (C162)', async () => {
const withBoth = await createSpeech({
youtubeUrl: 'https://www.youtube.com/watch?v=lLhRDkSPw0A',
vodPlaybackUrl: 'https://vod.camara.leg.br/old.mp4',
vodDownloadUrl: 'https://vod.camara.leg.br/old.mp4',
excerptTMs: 1786473834650,
eventStartAt: '2026-08-11T15:00',
})
const onlyYoutube = await createSpeech({ youtubeUrl: 'https://youtu.be/lLhRDkSPw0A' })
const { communicator } = await createUsers()
const both = await loadSpeechDetailPageData(
payload,
communicator,
withBoth,
undefined,
noVideoStart,
)
expect(both.youtubeVideoId).toBe('lLhRDkSPw0A')
expect(both.youtubeOffsetSeconds).toBe(2634)
expect(both.vodResolvable).toBe(true)
const youtubeOnly = await loadSpeechDetailPageData(
payload,
communicator,
onlyYoutube,
undefined,
noVideoStart,
)
expect(youtubeOnly.youtubeVideoId).toBe('lLhRDkSPw0A')
expect(youtubeOnly.vodResolvable).toBe(false)
})
it('moves the YouTube offset to the video start, measured evidence winning (C172)', async () => {
// No measurement for this video: the anchor the resolver answers is applied.
const anchored = await createSpeech({
youtubeUrl: 'https://www.youtube.com/watch?v=lLhRDkSPw0A',
excerptTMs: 1786473834650,
eventStartAt: '2026-08-11T15:00',
})
// Fala 997's session video has a measured lag (13s): it wins with no anchor.
const measured = await createSpeech({
youtubeUrl: 'https://www.youtube.com/watch?v=DC_i9Kp1LVk',
excerptTMs: 1786485082000,
eventStartAt: '2026-08-11T15:32',
})
const { communicator } = await createUsers()
const withAnchor = await loadSpeechDetailPageData(
payload,
communicator,
anchored,
undefined,
async () => '2026-08-11T18:00:40Z',
)
// 2634s of session offset − 40s of video start delay.
expect(withAnchor.youtubeOffsetSeconds).toBe(2594)
let anchorCalls = 0
const withEvidence = await loadSpeechDetailPageData(
payload,
communicator,
measured,
undefined,
async () => {
anchorCalls += 1
return null
},
)
// 11962s of session offset − the measured 13s, and the measured recording
// never pays the anchor lookup.
expect(withEvidence.youtubeOffsetSeconds).toBe(11949)
expect(anchorCalls).toBe(0)
})
it('keeps the direct download link off the list view model (C162)', async () => {
const id = await createSpeech({ vodDownloadUrl: 'https://vod.camara.leg.br/old.mp4' })
const { communicator } = await createUsers()
const data = await loadSpeechAcervoPageData(payload, communicator, {})
const row = data.rows.find((item) => item.id === id)
expect(row).toBeDefined()
expect(row).not.toHaveProperty('downloadUrl')
})
it('propagates the YouTube thumbnail to the list view model (C175)', async () => {
const runId = randomUUID().slice(0, 8)
const marker = `miniatura${runId}`
const withVideo = await createSpeech({
youtubeUrl: 'https://www.youtube.com/watch?v=lLhRDkSPw0A',
segments: [{ startSeconds: 0, endSeconds: 3, text: `A fala ${marker} no plenário` }],
})
const withoutVideo = await createSpeech({
segments: [{ startSeconds: 0, endSeconds: 3, text: `A fala ${marker} sem vídeo` }],
})
const { communicator } = await createUsers()
const data = await loadSpeechAcervoPageData(payload, communicator, { q: marker })
expect(data.rows.find((row) => row.id === withVideo)?.thumbnailUrl).toBe(
'https://i.ytimg.com/vi/lLhRDkSPw0A/hqdefault.jpg',
)
expect(data.rows.find((row) => row.id === withoutVideo)?.thumbnailUrl).toBeNull()
})
it('allows communicator/coordinator/candidate and denies advisor/leader', async () => {
const id = await createSpeech()
const { communicator, coordinator, candidate, advisor, leader } = await createUsers()
for (const reader of [communicator, coordinator, candidate]) {
const data = await loadSpeechAcervoPageData(payload, reader, {})
expect(data.rows.length).toBeGreaterThan(0)
}
for (const denied of [advisor, leader]) {
await expect(loadSpeechAcervoPageData(payload, denied, {})).rejects.toThrow(/permissão/i)
await expect(loadSpeechDetailPageData(payload, denied, id)).rejects.toThrow(/permissão/i)
}
})
it('throws the named not-found error for a missing speech', async () => {
const { communicator } = await createUsers()
await expect(
loadSpeechDetailPageData(payload, communicator, 999_999_999),
).rejects.toBeInstanceOf(SpeechNotFoundError)
})
})