teqo-backup/tests/int/archivePhotoPublicRead.int.spec.ts
Francisco Solla 5635e6522c feat(site): vira o site público para o mandato pós-eleição (S47)
Home abre no agradecimento pelos 152.049 votos com board 1+3, trajetória, bandeiras do mandato, galeria de fotos e rodapé institucional; chrome novo (header home/interno, lockup de nome, menu mobile acessível) e /artigos//fotos com título compacto.

As superfícies de campanha saem do ar (308 de /conteudos, /jingles e /cards), junto com newsletter, estúdio público de cards e UI da Central/jingles; download de foto e .ics perdem o 1313. Sem migration, sem Consent novo e sem apagar dados do CMS.
2026-10-22 02:16:08 -03:00

359 lines
13 KiB
TypeScript

// @vitest-environment node
import { randomUUID } from 'node:crypto'
import { mkdtemp, rm, writeFile } from 'node:fs/promises'
import { tmpdir } from 'node:os'
import { join } from 'node:path'
import type { Payload } from 'payload'
import { getPayload } from 'payload'
import { afterAll, beforeAll, 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')>()
// C248 — the route heals the grade with `after()`, which requires a request
// scope; the generation itself is exercised in its own spec.
return { ...actual, after: () => undefined }
})
import { GET as getArchivePhotoMedia } from '@/app/(frontend)/fotos/[id]/midia/route'
import { ARCHIVE_PHOTO_SLUG, type ArchivePhotoImport } from '@/lib/archivePhoto'
import { archivePhotoGradeFilename } from '@/lib/archivePhotoThumbnail'
import config from '@/payload.config'
import {
getApprovedArchivePhotoById,
getApprovedArchivePhotoItems,
hasPublishedArchivePhotos,
} from '@/utilities/archivePhotos/archivePhotoReads'
import { ingestArchivePhoto } from '@/utilities/flickr/archivePhotoIngest'
import { resolvePrivateMediaStaticDir } from '@/utilities/privateMedia/privateMediaResponse'
import { ARCHIVE_PHOTO_JPEG_BYTES } from '../helpers/archivePhotoFixture'
import { installCampaignFixtures } from '../helpers/campaignFixtures'
import { PHOTO_ALBUM_LEASE_KEY, withExclusiveTestDatabaseLease } from '../helpers/testDatabaseLease'
// C233 — the public album boundary over the real Payload `teqo_test`: the
// approve/draft/removed gate, the sticky removal, the removal-channel guard,
// the município snapshot the public read depends on and the public media
// route (approved 200, everything else the same silent 404).
let payload: Payload
const createdFlickrIds = new Set<string>()
const tempDirs: string[] = []
installCampaignFixtures({
getPayload: () => payload,
setPayload: (nextPayload) => {
payload = nextPayload
},
})
const CHANNEL = 'https://jorgessolla.example.org/remocao'
const record = (
flickrId: string,
overrides: Partial<ArchivePhotoImport> = {},
): ArchivePhotoImport => ({
flickrId,
sourceUrl: `https://www.flickr.com/photos/depjorgesolla/${flickrId}/`,
owner: '12345678@N00',
license: '0',
title: `Foto ${flickrId}`,
description: null,
tags: [],
takenAt: '2025-03-14 10:20:30',
postedAt: '2025-03-15T10:00:00.000Z',
albums: [],
geo: null,
exif: [],
originalUrl: `https://live.staticflickr.com/65535/${flickrId}_abcdef_o.jpg`,
originalKind: 'original',
...overrides,
})
const createPhoto = async (): Promise<{ id: number; flickrId: string }> => {
const flickrId = `c233-${randomUUID().slice(0, 8)}`
createdFlickrIds.add(flickrId)
const dir = await mkdtemp(join(tmpdir(), 'archive-photo-public-'))
tempDirs.push(dir)
const filePath = join(dir, `flickr-${flickrId}.jpg`)
await writeFile(filePath, ARCHIVE_PHOTO_JPEG_BYTES)
const created = await ingestArchivePhoto(payload, record(flickrId), { filePath })
if (created.status !== 'created') throw new Error(`fixture not created: ${created.status}`)
return { id: created.id, flickrId }
}
// The `photoAlbum` global is a single row created by the first write: every
// spec file that writes it serializes on the shared lease so parallel forks
// cannot race the creation into two rows (C234 specs write it too).
const setChannel = async (removalChannelUrl: string): Promise<void> => {
await withExclusiveTestDatabaseLease(payload, PHOTO_ALBUM_LEASE_KEY, () =>
payload.updateGlobal({
slug: 'photoAlbum',
data: { published: true, removalChannelUrl },
overrideAccess: true,
}),
)
}
/** Published is false here on purpose: the guard refuses an open album with no channel. */
const closeAlbumAndClearChannel = async (): Promise<void> => {
await withExclusiveTestDatabaseLease(payload, PHOTO_ALBUM_LEASE_KEY, () =>
payload.updateGlobal({
slug: 'photoAlbum',
data: { published: false, removalChannelUrl: null },
overrideAccess: true,
}),
)
}
const approve = async (id: number): Promise<void> => {
await payload.update({
collection: ARCHIVE_PHOTO_SLUG,
id,
data: { publicationStatus: 'approved' },
overrideAccess: true,
})
}
const statusOf = async (id: number): Promise<string> => {
const doc = await payload.findByID({
collection: ARCHIVE_PHOTO_SLUG,
id,
depth: 0,
overrideAccess: true,
})
return doc.publicationStatus
}
describe('public photo album reads (C233)', () => {
beforeAll(async () => {
payload = await getPayload({ config: await config })
await setChannel(CHANNEL)
})
afterAll(async () => {
const staticDir = resolvePrivateMediaStaticDir(payload, ARCHIVE_PHOTO_SLUG)
for (const flickrId of createdFlickrIds) {
// The approval hook warms the C248 grade as a sibling object; the delete
// below removes the original, so the derived file is cleaned here.
const photo = await payload.find({
collection: ARCHIVE_PHOTO_SLUG,
where: { flickrId: { equals: flickrId } },
depth: 0,
limit: 1,
overrideAccess: true,
})
const filename = photo.docs[0]?.filename
if (filename) {
await rm(join(staticDir, archivePhotoGradeFilename(filename)), { force: true }).catch(
() => undefined,
)
}
await payload.delete({
collection: ARCHIVE_PHOTO_SLUG,
where: { flickrId: { equals: flickrId } },
overrideAccess: true,
})
}
// The channel is left configured on purpose (the face specs' pattern):
// tearing it down here would race the approvals of every sibling album
// spec sharing the global; the tests that exercise the closed/absent
// states restore the open state themselves.
await Promise.all(tempDirs.map((dir) => rm(dir, { recursive: true, force: true })))
})
it('shows only approved photos and keeps draft/removed invisible', async () => {
const draft = await createPhoto()
const approved = await createPhoto()
const removed = await createPhoto()
await approve(approved.id)
await approve(removed.id)
await payload.update({
collection: ARCHIVE_PHOTO_SLUG,
id: removed.id,
data: { publicationStatus: 'removed' },
overrideAccess: true,
})
const items = await getApprovedArchivePhotoItems()
const ids = items.map((item) => item.id)
expect(ids).toContain(approved.id)
expect(ids).not.toContain(draft.id)
expect(ids).not.toContain(removed.id)
expect(await getApprovedArchivePhotoById(approved.id)).not.toBeNull()
expect(await getApprovedArchivePhotoById(draft.id)).toBeNull()
expect(await getApprovedArchivePhotoById(removed.id)).toBeNull()
expect(await getApprovedArchivePhotoById(999_999_999)).toBeNull()
expect(await hasPublishedArchivePhotos()).toBe(true)
})
it('refuses to approve without a valid removal channel and never lets the pipeline flip the status', async () => {
const photo = await createPhoto()
await closeAlbumAndClearChannel()
await expect(approve(photo.id)).rejects.toThrow()
expect(await statusOf(photo.id)).toBe('draft')
// The global guard is the other half: an open album cannot lose its channel.
await expect(
payload.updateGlobal({
slug: 'photoAlbum',
data: { published: true, removalChannelUrl: null },
overrideAccess: true,
}),
).rejects.toThrow()
await setChannel(CHANNEL)
await approve(photo.id)
// The cataloguing write path carries its own context and never touches the
// publication state.
await payload.update({
collection: ARCHIVE_PHOTO_SLUG,
id: photo.id,
data: { catalog: { caption: 'Legenda recatalogada', catalogedAt: new Date().toISOString() } },
context: { archivePhotoCatalog: true },
overrideAccess: true,
})
expect(await statusOf(photo.id)).toBe('approved')
// `removed` is sticky: a system write on top keeps it removed.
await payload.update({
collection: ARCHIVE_PHOTO_SLUG,
id: photo.id,
data: { publicationStatus: 'removed' },
overrideAccess: true,
})
await payload.update({
collection: ARCHIVE_PHOTO_SLUG,
id: photo.id,
data: { catalog: { caption: 'Legenda de novo', catalogedAt: new Date().toISOString() } },
context: { archivePhotoCatalog: true },
overrideAccess: true,
})
expect(await statusOf(photo.id)).toBe('removed')
expect(await getApprovedArchivePhotoById(photo.id)).toBeNull()
})
it('keeps the single-field global update safe — the ops CLI path (C247)', async () => {
// `pnpm ops:global` writes ONE field at a time. Payload merges the
// original doc into the global `beforeValidate`, so the fail-closed channel
// guard still sees the stored value and the sibling fields survive.
await setChannel(CHANNEL)
const updated = await withExclusiveTestDatabaseLease(payload, PHOTO_ALBUM_LEASE_KEY, () =>
payload.updateGlobal({
slug: 'photoAlbum',
data: { selfieSearchEnabled: true },
overrideAccess: true,
}),
)
expect(updated.selfieSearchEnabled).toBe(true)
expect(updated.published).toBe(true)
expect(updated.removalChannelUrl).toBe(CHANNEL)
// The partial write alone cannot open the album without a stored channel:
// the same fail-closed guard refuses it.
await closeAlbumAndClearChannel()
await expect(
withExclusiveTestDatabaseLease(payload, PHOTO_ALBUM_LEASE_KEY, () =>
payload.updateGlobal({
slug: 'photoAlbum',
data: { published: true },
overrideAccess: true,
}),
),
).rejects.toThrow()
const closed = await payload.findGlobal({
slug: 'photoAlbum',
depth: 0,
overrideAccess: true,
})
expect(closed.published).toBe(false)
await withExclusiveTestDatabaseLease(payload, PHOTO_ALBUM_LEASE_KEY, () =>
payload.updateGlobal({
slug: 'photoAlbum',
data: { selfieSearchEnabled: false },
overrideAccess: true,
}),
)
// Restore the beforeAll state the sibling specs rely on.
await setChannel(CHANNEL)
})
it('snapshots the município name/slug on the row so the public read never touches the campaign-only collection', async () => {
const photo = await createPhoto()
const municipality = await payload.find({
collection: 'municipality',
where: { slug: { equals: 'feira-de-santana' } },
depth: 0,
limit: 1,
overrideAccess: true,
})
const municipalityId = municipality.docs[0]?.id
expect(municipalityId).toBeDefined()
const updated = await payload.update({
collection: ARCHIVE_PHOTO_SLUG,
id: photo.id,
data: { catalog: { municipality: municipalityId } },
overrideAccess: true,
})
expect(updated.municipalityName).toBe('Feira de Santana')
expect(updated.municipalitySlug).toBe('feira-de-santana')
await approve(photo.id)
const item = await getApprovedArchivePhotoById(photo.id)
expect(item?.municipalityName).toBe('Feira de Santana')
expect(item?.municipalitySlug).toBe('feira-de-santana')
})
it('serves the approved photo through the media route and 404s every other state', async () => {
const approved = await createPhoto()
await approve(approved.id)
const draft = await createPhoto()
const thumbnail = await getArchivePhotoMedia(
new Request(`http://localhost/fotos/${approved.id}/midia?tamanho=grade`),
{ params: Promise.resolve({ id: String(approved.id) }) },
)
expect(thumbnail.status).toBe(200)
expect(thumbnail.headers.get('Content-Type')).toBe('image/jpeg')
expect(thumbnail.headers.get('X-Robots-Tag')).toBe('noindex')
expect(thumbnail.headers.get('Vary')).toBe('Accept')
const download = await getArchivePhotoMedia(
new Request(`http://localhost/fotos/${approved.id}/midia?download=1`),
{ params: Promise.resolve({ id: String(approved.id) }) },
)
expect(download.status).toBe(200)
expect(download.headers.get('Content-Disposition')).toContain('attachment')
expect(download.headers.get('Content-Disposition')).toContain(`jorge-solla-foto-${approved.id}`)
for (const id of [draft.id, 999_999_999]) {
const response = await getArchivePhotoMedia(
new Request(`http://localhost/fotos/${id}/midia`),
{
params: Promise.resolve({ id: String(id) }),
},
)
expect(response.status).toBe(404)
expect(response.headers.get('X-Robots-Tag')).toBe('noindex')
}
const garbage = await getArchivePhotoMedia(new Request('http://localhost/fotos/x/midia'), {
params: Promise.resolve({ id: 'x' }),
})
expect(garbage.status).toBe(404)
})
})